Repository: electron/fiddle
Pull request #560 (2021-01-07 00:04:37)

feat: system-synced dark mode (renderer-only)
Supersedes #470, but doesn't require any use of the main process!

coveralls: 
[![Coverage Status](https://coveralls.io/builds/36244878/badge)](https://coveralls.io/builds/36244878)

Coverage decreased (-0.5%) to 92.963% when pulling **afb47a92ebb607da84f229e879f2d865f48e800b on erick/css-system-theme** into **33148a952ae1e1b6285bfe4a18bbed38576d07a0 on master**.

FAWLKTZ: Simple simon would say let there be the next one to answer  it .  Win tip dir. defrag. Delete the fiddle add  popup instead

Patch:
diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx
index ffa9db2f..34eb67cd 100644
--- a/src/renderer/app.tsx
+++ b/src/renderer/app.tsx
@@ -1,7 +1,7 @@
 import { initSentry } from '../sentry';
 initSentry();
 
-import { when } from 'mobx';
+import { reaction, when } from 'mobx';
 import * as MonacoType from 'monaco-editor';
 
 import { ipcRenderer } from 'electron';
@@ -24,6 +24,7 @@ import { RemoteLoader } from './remote-loader';
 import { Runner } from './runner';
 import { appState } from './state';
 import { getTheme } from './themes';
+import { defaultDark, defaultLight } from './themes-defaults';
 import { TouchBarManager } from './touch-bar-manager';
 
 /**
@@ -180,7 +181,7 @@ export class App {
    * render process.
    */
   public async setup(): Promise<void | Element | React.Component> {
-    this.setupTheme();
+    this.loadTheme();
 
     const React = await import('react');
     const { render } = await import('react-dom');
@@ -202,18 +203,51 @@ export class App {
     const rendered = render(app, document.getElementById('app'));
 
     this.setupResizeListener();
+    this.setupThemeListeners();
 
     ipcRenderer.send(WEBCONTENTS_READY_FOR_IPC_SIGNAL);
 
     return rendered;
   }
 
+  public async setupThemeListeners() {
+    const setSystemTheme = (prefersDark: boolean) => {
+      if (prefersDark) {
+        this.state.setTheme(defaultDark.file);
+      } else {
+        this.state.setTheme(defaultLight.file);
+      }
+    };
+
+    // match theme to system when box is ticked
+    reaction(
+      () => this.state.isUsingSystemTheme,
+      () => {
+        if (this.state.isUsingSystemTheme && !!window.matchMedia) {
+          const { matches } = window.matchMedia('(prefers-color-scheme: dark)');
+          setSystemTheme(matches);
+        }
+      },
+    );
+
+    // change theme when system theme changes
+    if (!!window.matchMedia) {
+      window
+        .matchMedia('(prefers-color-scheme: dark)')
+        .addEventListener('change', ({ matches }) => {
+          if (this.state.isUsingSystemTheme) {
+            setSystemTheme(matches);
+          }
+        });
+    }
+  }
+
   /**
    * Loads theme CSS into the HTML document.
    *
    * @returns {Promise<void>}
    */
-  public async setupTheme(): Promise<void> {
+  public async loadTheme(): Promise<void> {
     const tag: HTMLStyleElement | null = document.querySelector(
       'style#fiddle-theme',
     );
diff --git a/src/renderer/components/settings-general-appearance.tsx b/src/renderer/components/settings-general-appearance.tsx
index 1045332c..621977fd 100644
--- a/src/renderer/components/settings-general-appearance.tsx
+++ b/src/renderer/components/settings-general-appearance.tsx
@@ -1,7 +1,14 @@
-import { Button, Callout, FormGroup, MenuItem } from '@blueprintjs/core';
+import {
+  Button,
+  Callout,
+  Checkbox,
+  FormGroup,
+  MenuItem,
+} from '@blueprintjs/core';
 import { ItemPredicate, ItemRenderer, Select } from '@blueprintjs/select';
 import { shell } from 'electron';
 import * as fsType from 'fs-extra';
+import { reaction } from 'mobx';
 import { observer } from 'mobx-react';
 import * as path from 'path';
 import * as React from 'react';
@@ -84,6 +91,7 @@ export class AppearanceSettings extends React.Component<
     this.handleChange = this.handleChange.bind(this);
     this.openThemeFolder = this.openThemeFolder.bind(this);
     this.handleAddTheme = this.handleAddTheme.bind(this);
+    this.handleThemeSource = this.handleThemeSource.bind(this);
 
     this.state = {
       themes: [],
@@ -95,6 +103,15 @@ export class AppearanceSettings extends React.Component<
         (theme && themes.find(({ file }) => file === theme)) || themes[0];
 
       this.setState({ themes, selectedTheme });
+
+      // set up mobx so that changes from system sync are reflected in picker
+      reaction(
+        () => this.props.appState.theme,
+        async () => {
+          const selectedTheme = await getTheme(this.props.appState.theme);
+          this.setState({ selectedTheme });
+        },
+      );
     });
 
     this.createNewThemeFromCurrent = this.createNewThemeFromCurrent.bind(this);
@@ -172,16 +189,28 @@ export class AppearanceSettings extends React.Component<
 
   public render() {
     const { selectedTheme } = this.state;
+    const { isUsingSystemTheme } = this.props.appState;
     const selectedName =
       (selectedTheme && selectedTheme.name) || 'Select a theme';
 
     return (
       <div className="settings-appearance">
         <h4>Appearance</h4>
-        <FormGroup label="Choose your theme" inline={true}>
+        <Checkbox
+          label="Sync theme with system setting"
+          checked={isUsingSystemTheme}
+          onChange={this.handleThemeSource}
+        />
+        <FormGroup
+          label="Choose your theme"
+          disabled={isUsingSystemTheme}
+          inline={true}
+        >
           <ThemeSelect
             filterable={true}
+            disabled={isUsingSystemTheme}
             items={this.state.themes}
+            activeItem={selectedTheme}
             itemRenderer={renderItem}
             itemPredicate={filterItem}
             onItemSelect={this.handleChange}
@@ -195,10 +224,11 @@ export class AppearanceSettings extends React.Component<
               text={selectedName}
               icon="tint"
               onClick={() => this.props.toggleHasPopoverOpen()}
+              disabled={isUsingSystemTheme}
             />
           </ThemeSelect>
         </FormGroup>
-        <Callout>
+        <Callout hidden={isUsingSystemTheme}>
           <p>
             To add themes, add JSON theme files to{' '}
             <a id="open-theme-folder" onClick={this.openThemeFolder}>
@@ -232,4 +262,10 @@ export class AppearanceSettings extends React.Component<
   public handleAddTheme(): void {
     this.props.appState.toggleAddMonacoThemeDialog();
   }
+
+  public handleThemeSource(event: React.FormEvent<HTMLInputElement>): void {
+    const { appState } = this.props;
+    const { checked } = event.currentTarget;
+    appState.isUsingSystemTheme = checked;
+  }
 }
diff --git a/src/renderer/state.ts b/src/renderer/state.ts
index 80c0fb34..a6257c55 100644
--- a/src/renderer/state.ts
+++ b/src/renderer/state.ts
@@ -101,9 +101,10 @@ export class AppState {
   @observable public gitHubPublishAsPublic = !!this.retrieve(
     'gitHubPublishAsPublic',
   );
-  @observable public channelsToShow: Array<
-    ElectronReleaseChannel
-  > = (this.retrieve('channelsToShow') as Array<ElectronReleaseChannel>) || [
+  @observable
+  public channelsToShow: Array<ElectronReleaseChannel> = (this.retrieve(
+    'channelsToShow',
+  ) as Array<ElectronReleaseChannel>) || [
     ElectronReleaseChannel.stable,
     ElectronReleaseChannel.beta,
   ];
@@ -123,6 +124,9 @@ export class AppState {
   @observable public isClearingConsoleOnRun = !!this.retrieve(
     'isClearingConsoleOnRun',
   );
+  @observable public isUsingSystemTheme = !!(
+    this.retrieve('isUsingSystemTheme') ?? true
+  );
   @observable public executionFlags: Array<string> =
     (this.retrieve('executionFlags') as Array<string>) === null
       ? []
@@ -149,9 +153,8 @@ export class AppState {
   };
   @observable public genericDialogLastResult: boolean | null = null;
   @observable public genericDialogLastInput: string | null = null;
-  @observable public mosaicArrangement: MosaicNode<
-    MosaicId
-  > | null = DEFAULT_MOSAIC_ARRANGEMENT;
+  @observable
+  public mosaicArrangement: MosaicNode<MosaicId> | null = DEFAULT_MOSAIC_ARRANGEMENT;
   @observable public templateName: string | undefined;
   @observable public currentDocsDemoPage: DocsDemoPage = DocsDemoPage.DEFAULT;
   @observable public localTypeWatcher: fsType.FSWatcher | undefined;
@@ -227,6 +230,7 @@ export class AppState {
     autorun(() =>
       this.save('isClearingConsoleOnRun', this.isClearingConsoleOnRun),
     );
+    autorun(() => this.save('isUsingSystemTheme', this.isUsingSystemTheme));
     autorun(() => this.save('gitHubAvatarUrl', this.gitHubAvatarUrl));
     autorun(() => this.save('gitHubLogin', this.gitHubLogin));
     autorun(() => this.save('gitHubName', this.gitHubName));
@@ -435,7 +439,7 @@ export class AppState {
   @action public setTheme(fileName?: string) {
     this.theme = fileName || '';
     activateTheme(undefined, undefined, fileName);
-    window.ElectronFiddle.app.setupTheme();
+    window.ElectronFiddle.app.loadTheme();
   }
 
   @action public setGenericDialogOptions(opts: GenericDialogOptions) {
@@ -843,7 +847,7 @@ export class AppState {
       | null
       | boolean,
   ) {
-    if (value) {
+    if (value !== null && value !== undefined) {
       const _value =
         typeof value === 'object' ? JSON.stringify(value) : value.toString();
 


Test patch:
diff --git a/tests/mocks/app.ts b/tests/mocks/app.ts
index dac32620..2325985b 100644
--- a/tests/mocks/app.ts
+++ b/tests/mocks/app.ts
@@ -14,7 +14,7 @@ export class AppMock {
     css: 'css-content',
   }));
 
-  public setupTheme = jest.fn();
+  public loadTheme = jest.fn();
 
   public typeDefDisposable = {
     dispose: jest.fn(),
diff --git a/tests/mocks/state.ts b/tests/mocks/state.ts
index 1c6403f0..9ea027d1 100644
--- a/tests/mocks/state.ts
+++ b/tests/mocks/state.ts
@@ -1,9 +1,10 @@
 import { observable } from 'mobx';
 
 export class MockState {
-  @observable public isGenericDialogShowing = false;
-  @observable public gistId = '';
   @observable public closedPanels = {};
+  @observable public gistId = '';
   @observable public isConsoleShowing = true;
+  @observable public isGenericDialogShowing = false;
+  @observable public isUsingSystemTheme = true;
   @observable public output = [];
 }
diff --git a/tests/renderer/app-spec.tsx b/tests/renderer/app-spec.tsx
index c2cca04f..fe970934 100644
--- a/tests/renderer/app-spec.tsx
+++ b/tests/renderer/app-spec.tsx
@@ -5,6 +5,7 @@ import { ElectronFiddleMock } from '../mocks/electron-fiddle';
 import { MockState } from '../mocks/state';
 import { overridePlatform, resetPlatform } from '../utils';
 import { EditorId } from '../../src/interfaces';
+import { defaultDark, defaultLight } from '../../src/renderer/themes-defaults';
 
 jest.mock('../../src/renderer/file-manager', () =>
   require('../mocks/file-manager'),
@@ -26,7 +27,7 @@ jest.mock('../../src/renderer/components/output-editors-wrapper', () => ({
   OutputEditorsWrapper: () => 'OutputEditorsWrapper;',
 }));
 
-describe('Editors component', () => {
+describe('App component', () => {
   beforeAll(() => {
     document.body.innerHTML = '<div id="app" />';
   });
@@ -398,12 +399,12 @@ describe('Editors component', () => {
     });
   });
 
-  describe('setupTheme()', () => {
+  describe('loadTheme()', () => {
     it(`adds the current theme's css to the document`, async () => {
       document.head!.innerHTML = "<style id='fiddle-theme'></style>";
 
       const app = new App();
-      await app.setupTheme();
+      await app.loadTheme();
 
       expect(document.head!.innerHTML).toEqual(
         `<style id="fiddle-theme">
@@ -435,7 +436,7 @@ describe('Editors component', () => {
       const app = new App();
       app.state.theme = 'defaultLight';
 
-      await app.setupTheme();
+      await app.loadTheme();
 
       expect(document.body.classList.value).toBe('');
     });
@@ -444,9 +445,98 @@ describe('Editors component', () => {
       const app = new App();
       app.state.theme = 'custom-dark';
 
-      await app.setupTheme();
+      await app.loadTheme();
 
       expect(document.body.classList.value).toBe('bp3-dark');
     });
   });
+
+  describe('setupThemeListeners()', () => {
+    let app: App;
+    const addEventListenerMock = jest.fn();
+    beforeEach(() => {
+      // matchMedia mock
+      Object.defineProperty(window, 'matchMedia', {
+        writable: true,
+        value: jest.fn().mockImplementation((query) => ({
+          matches: false,
+          media: query,
+          onchange: null,
+          addListener: jest.fn(), // Deprecated
+          removeListener: jest.fn(), // Deprecated
+          addEventListener: addEventListenerMock,
+          removeEventListener: jest.fn(),
+          dispatchEvent: jest.fn(),
+        })),
+      });
+
+      // app mock
+      app = new App();
+      (app.state as Partial<AppState>) = new MockState();
+      app.state.isUsingSystemTheme = true;
+      app.state.setTheme = jest.fn();
+    });
+
+    describe('isUsingSystemTheme reaction', () => {
+      it('ignores system theme changes when not isUsingSystemTheme', () => {
+        app.state.isUsingSystemTheme = true;
+        app.setupThemeListeners();
+        app.state.isUsingSystemTheme = false;
+        expect(app.state.setTheme).not.toHaveBeenCalled();
+      });
+
+      it('sets theme according to system when isUsingSystemTheme', () => {
+        app.setupThemeListeners();
+
+        // isUsingSystemTheme and prefersDark
+        app.state.isUsingSystemTheme = false;
+        (window.matchMedia as jest.Mock).mockReturnValue({
+          matches: true,
+        });
+        app.state.isUsingSystemTheme = true;
+        expect(app.state.setTheme).toHaveBeenCalledWith(defaultDark.file);
+
+        // isUsingSystemTheme and not prefersDark
+        app.state.isUsingSystemTheme = false;
+        (window.matchMedia as jest.Mock).mockReturnValue({
+          matches: false,
+        });
+        app.state.isUsingSystemTheme = true;
+        expect(app.state.setTheme).toHaveBeenCalledWith(defaultLight.file);
+      });
+    });
+
+    describe('prefers-color-scheme event listener', () => {
+      it('adds an event listener to the "change" event', () => {
+        app.setupThemeListeners();
+        expect(addEventListenerMock).toHaveBeenCalled();
+        const event = addEventListenerMock.mock.calls[0][0];
+        expect(event).toBe('change');
+      });
+
+      it('does nothing if not isUsingSystemTheme', () => {
+        app.setupThemeListeners();
+        const callback = addEventListenerMock.mock.calls[0][1];
+        app.state.isUsingSystemTheme = false;
+        callback({ matches: true });
+        expect(app.state.setTheme).not.toHaveBeenCalled();
+      });
+
+      it('sets dark theme if isUsingSystemTheme and prefers dark', () => {
+        app.setupThemeListeners();
+        const callback = addEventListenerMock.mock.calls[0][1];
+        app.state.isUsingSystemTheme = true;
+        callback({ matches: true });
+        expect(app.state.setTheme).toHaveBeenCalledWith(defaultDark.file);
+      });
+
+      it('sets light theme if isUsingSystemTheme and not prefers dark', () => {
+        app.setupThemeListeners();
+        const callback = addEventListenerMock.mock.calls[0][1];
+        app.state.isUsingSystemTheme = true;
+        callback({ matches: false });
+        expect(app.state.setTheme).toHaveBeenCalledWith(defaultLight.file);
+      });
+    });
+  });
 });
diff --git a/tests/renderer/components/__snapshots__/settings-general-appearance-spec.tsx.snap b/tests/renderer/components/__snapshots__/settings-general-appearance-spec.tsx.snap
index ebffa6b9..2995bda7 100644
--- a/tests/renderer/components/__snapshots__/settings-general-appearance-spec.tsx.snap
+++ b/tests/renderer/components/__snapshots__/settings-general-appearance-spec.tsx.snap
@@ -24,6 +24,10 @@ exports[`AppearanceSettings component renders 1`] = `
   <h4>
     Appearance
   </h4>
+  <Blueprint3.Checkbox
+    label="Sync theme with system setting"
+    onChange={[Function]}
+  />
   <Blueprint3.FormGroup
     inline={true}
     label="Choose your theme"
diff --git a/tests/renderer/state-spec.ts b/tests/renderer/state-spec.ts
index cad99a78..977952cf 100644
--- a/tests/renderer/state-spec.ts
+++ b/tests/renderer/state-spec.ts
@@ -421,18 +421,18 @@ describe('AppState', () => {
   });
 
   describe('setTheme()', () => {
-    it('calls setupTheme()', () => {
+    it('calls loadTheme()', () => {
       appState.setTheme('custom');
 
       expect(appState.theme).toBe('custom');
-      expect(window.ElectronFiddle.app.setupTheme).toHaveBeenCalledTimes(1);
+      expect(window.ElectronFiddle.app.loadTheme).toHaveBeenCalledTimes(1);
     });
 
     it('handles a missing theme name', () => {
       appState.setTheme();
 
       expect(appState.theme).toBe('');
-      expect(window.ElectronFiddle.app.setupTheme).toHaveBeenCalledTimes(1);
+      expect(window.ElectronFiddle.app.loadTheme).toHaveBeenCalledTimes(1);
     });
   });