Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic switching between Dark Mode and Light #6051

Merged
merged 24 commits into from
Jan 24, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/src/lib/app-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,9 @@ export interface IAppState {
/** The currently selected appearance (aka theme) */
readonly selectedTheme: ApplicationTheme

/** Whether we should automatically change the currently selected appearance (aka theme) */
readonly automaticallySwitchTheme: boolean

/**
* A map keyed on a user account (GitHub.com or GitHub Enterprise)
* containing an object with repositories that the authenticated
Expand Down
7 changes: 7 additions & 0 deletions app/src/lib/dispatcher/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,13 @@ export class Dispatcher {
return this.appStore._setSelectedTheme(theme)
}

/**
* Set the automatically switch application-wide theme
*/
public onAutomaticallySwitchThemeChanged(theme: boolean) {
return this.appStore._setAutomaticallySwitchTheme(theme)
}

/**
* Increments either the `repoWithIndicatorClicked` or
* the `repoWithoutIndicatorClicked` metric
Expand Down
17 changes: 17 additions & 0 deletions app/src/lib/get-os.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,20 @@ export function getOS() {
return `${OS.type()} ${OS.release()}`
}
}

/** See the OS we're currently running on is at least Mojave. */
export function isMojaveOrLater() {
if (__DARWIN__) {
const parser = new UAParser()
const os = parser.getOS()

if (os.version === undefined) {
return false
}

const [major, minor] = os.version.split('.')

return major === '10' && minor > '13'
}
return false
}
24 changes: 24 additions & 0 deletions app/src/lib/stores/app-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,14 @@ import {
} from '../../models/progress'
import { Popup, PopupType } from '../../models/popup'
import { IGitAccount } from '../../models/git-account'
import { themeChangeMonitor } from '../../ui/lib/theme-change-monitor'
import { getAppPath } from '../../ui/lib/app-proxy'
import {
ApplicationTheme,
getPersistedTheme,
setPersistedTheme,
getAutoSwitchPersistedTheme,
setAutoSwitchPersistedTheme,
} from '../../ui/lib/application-theme'
import {
getAppMenu,
Expand Down Expand Up @@ -292,6 +295,7 @@ export class AppStore extends TypedBaseStore<IAppState> {

private selectedBranchesTab = BranchesTab.Branches
private selectedTheme = ApplicationTheme.Light
private automaticallySwitchTheme = false

public constructor(
private readonly gitHubUserStore: GitHubUserStore,
Expand Down Expand Up @@ -512,6 +516,7 @@ export class AppStore extends TypedBaseStore<IAppState> {
selectedCloneRepositoryTab: this.selectedCloneRepositoryTab,
selectedBranchesTab: this.selectedBranchesTab,
selectedTheme: this.selectedTheme,
automaticallySwitchTheme: this.automaticallySwitchTheme,
apiRepositories: this.apiRepositoriesStore.getState(),
}
}
Expand Down Expand Up @@ -1385,6 +1390,14 @@ export class AppStore extends TypedBaseStore<IAppState> {
: parseInt(imageDiffTypeValue)

this.selectedTheme = getPersistedTheme()
this.automaticallySwitchTheme = getAutoSwitchPersistedTheme()

themeChangeMonitor.onThemeChanged(theme => {
if (this.automaticallySwitchTheme) {
this.selectedTheme = theme
this.emitUpdate()
}
})

this.emitUpdateNow()

Expand Down Expand Up @@ -3990,6 +4003,17 @@ export class AppStore extends TypedBaseStore<IAppState> {
return Promise.resolve()
}

/**
* Set the application-wide theme
*/
public _setAutomaticallySwitchTheme(automaticallySwitchTheme: boolean) {
setAutoSwitchPersistedTheme(automaticallySwitchTheme)
this.automaticallySwitchTheme = automaticallySwitchTheme
this.emitUpdate()

return Promise.resolve()
}

public async _resolveCurrentEditor() {
const match = await findEditorOrDefault(this.selectedExternalEditor)
const resolvedExternalEditor = match != null ? match.editor : null
Expand Down
1 change: 1 addition & 0 deletions app/src/ui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1095,6 +1095,7 @@ export class App extends React.Component<IAppProps, IAppState> {
onDismissed={this.onPopupDismissed}
selectedShell={this.state.selectedShell}
selectedTheme={this.state.selectedTheme}
automaticallySwitchTheme={this.state.automaticallySwitchTheme}
/>
)
case PopupType.MergeBranch: {
Expand Down
22 changes: 21 additions & 1 deletion app/src/ui/lib/application-theme.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { assertNever } from '../../lib/fatal-error'

import { getBoolean, setBoolean } from '../../lib/local-storage'
/**
* A set of the user-selectable appearances (aka themes)
*/
Expand Down Expand Up @@ -54,3 +54,23 @@ export function getPersistedThemeName(): string {
export function setPersistedTheme(theme: ApplicationTheme) {
localStorage.setItem(applicationThemeKey, getThemeName(theme))
}

// The key under which the decision to automatically switch the theme is persisted
// in localStorage.
const automaticallySwitchApplicationThemeKey = 'autoSwitchTheme'

/**
* Load the whether or not the user wishes to automatically switch the selected theme from the persistent
* store (localStorage). If no theme is selected the default
* theme will be returned.
*/
export function getAutoSwitchPersistedTheme(): boolean {
return getBoolean(automaticallySwitchApplicationThemeKey, false)
}

/**
* Store whether or not the user wishes to automatically switch the selected theme in the persistent store (localStorage).
*/
export function setAutoSwitchPersistedTheme(autoSwitchTheme: boolean) {
setBoolean(automaticallySwitchApplicationThemeKey, autoSwitchTheme)
}
20 changes: 20 additions & 0 deletions app/src/ui/lib/dark-theme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { remote } from 'electron'
import { isMojaveOrLater } from '../../lib/get-os'

export function supportsDarkMode() {
if (!__DARWIN__) {
return false
}

return isMojaveOrLater()
}

export function isDarkModeEnabled() {
if (!supportsDarkMode()) {
return false
}

// remote is an IPC call, so if we know there's no point making this call
// we should avoid paying the IPC tax
return remote.systemPreferences.isDarkMode()
}
61 changes: 61 additions & 0 deletions app/src/ui/lib/theme-change-monitor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { remote } from 'electron'
import { ApplicationTheme } from './application-theme'
import { IDisposable, Disposable, Emitter } from 'event-kit'
import { supportsDarkMode, isDarkModeEnabled } from './dark-theme'

class ThemeChangeMonitor implements IDisposable {
private readonly emitter = new Emitter()

private subscriptionID: number | null = null

public constructor() {
this.subscribe()
}

public dispose() {
this.unsubscribe()
}

private subscribe = () => {
if (!supportsDarkMode()) {
return
}

this.subscriptionID = remote.systemPreferences.subscribeNotification(
'AppleInterfaceThemeChangedNotification',
this.onThemeNotificationFromOS
)
}

private onThemeNotificationFromOS = (event: string, userInfo: any) => {
const darkModeEnabled = isDarkModeEnabled()

const theme = darkModeEnabled
? ApplicationTheme.Dark
: ApplicationTheme.Light
this.emitThemeChanged(theme)
}

private unsubscribe = () => {
if (this.subscriptionID !== null) {
remote.systemPreferences.unsubscribeNotification(this.subscriptionID)
say25 marked this conversation as resolved.
Show resolved Hide resolved
this.subscriptionID = null
}
}

public onThemeChanged(fn: (theme: ApplicationTheme) => void): Disposable {
return this.emitter.on('theme-changed', fn)
}

private emitThemeChanged(theme: ApplicationTheme) {
this.emitter.emit('theme-changed', theme)
}
}

// this becomes our singleton that we can subscribe to from anywhere
export const themeChangeMonitor = new ThemeChangeMonitor()
iAmWillShepherd marked this conversation as resolved.
Show resolved Hide resolved

// this ensures we cleanup any existing subscription on exit
remote.app.on('will-quit', () => {
iAmWillShepherd marked this conversation as resolved.
Show resolved Hide resolved
themeChangeMonitor.dispose()
})
51 changes: 49 additions & 2 deletions app/src/ui/preferences/appearance.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as React from 'react'
import { supportsDarkMode, isDarkModeEnabled } from '../lib/dark-theme'
import { Checkbox, CheckboxValue } from '../lib/checkbox'
import { Row } from '../lib/row'
import { DialogContent } from '../dialog'
import {
VerticalSegmentedControl,
Expand All @@ -10,6 +13,8 @@ import { fatalError } from '../../lib/fatal-error'
interface IAppearanceProps {
readonly selectedTheme: ApplicationTheme
readonly onSelectedThemeChanged: (theme: ApplicationTheme) => void
readonly automaticallySwitchTheme: boolean
readonly onAutomaticallySwitchThemeChanged: (checked: boolean) => void
}

const themes: ReadonlyArray<ISegmentedItem> = [
Expand All @@ -30,20 +35,62 @@ export class Appearance extends React.Component<IAppearanceProps, {}> {
} else {
fatalError(`Unknown theme index ${index}`)
}
this.props.onAutomaticallySwitchThemeChanged(false)
}

private onAutomaticallySwitchThemeChanged = (
event: React.FormEvent<HTMLInputElement>
) => {
const value = event.currentTarget.checked

if (value) {
this.onSelectedThemeChanged(isDarkModeEnabled() ? 1 : 0)
}

this.props.onAutomaticallySwitchThemeChanged(value)
}

public render() {
return (
<DialogContent>
{this.renderThemeOptions()}
{this.renderAutoSwitcherOption()}
</DialogContent>
)
}

public renderThemeOptions() {
const selectedIndex =
this.props.selectedTheme === ApplicationTheme.Dark ? 1 : 0

return (
<DialogContent>
<Row>
<VerticalSegmentedControl
items={themes}
selectedIndex={selectedIndex}
onSelectionChanged={this.onSelectedThemeChanged}
/>
</DialogContent>
</Row>
)
}

public renderAutoSwitcherOption() {
if (!supportsDarkMode()) {
return null
}

return (
<Row>
<Checkbox
label="Automatically switch theme to match system theme."
value={
this.props.automaticallySwitchTheme
? CheckboxValue.On
: CheckboxValue.Off
}
onChange={this.onAutomaticallySwitchThemeChanged}
/>
</Row>
)
}
}
15 changes: 15 additions & 0 deletions app/src/ui/preferences/preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface IPreferencesProps {
readonly selectedExternalEditor?: ExternalEditor
readonly selectedShell: Shell
readonly selectedTheme: ApplicationTheme
readonly automaticallySwitchTheme: boolean
}

interface IPreferencesState {
Expand All @@ -46,6 +47,7 @@ interface IPreferencesState {
readonly optOutOfUsageTracking: boolean
readonly confirmRepositoryRemoval: boolean
readonly confirmDiscardChanges: boolean
readonly automaticallySwitchTheme: boolean
readonly availableEditors: ReadonlyArray<ExternalEditor>
readonly selectedExternalEditor?: ExternalEditor
readonly availableShells: ReadonlyArray<Shell>
Expand All @@ -70,6 +72,7 @@ export class Preferences extends React.Component<
optOutOfUsageTracking: false,
confirmRepositoryRemoval: false,
confirmDiscardChanges: false,
automaticallySwitchTheme: false,
selectedExternalEditor: this.props.selectedExternalEditor,
availableShells: [],
selectedShell: this.props.selectedShell,
Expand Down Expand Up @@ -212,6 +215,10 @@ export class Preferences extends React.Component<
<Appearance
selectedTheme={this.props.selectedTheme}
onSelectedThemeChanged={this.onSelectedThemeChanged}
automaticallySwitchTheme={this.props.automaticallySwitchTheme}
onAutomaticallySwitchThemeChanged={
this.onAutomaticallySwitchThemeChanged
}
/>
)
case PreferencesTab.Advanced: {
Expand Down Expand Up @@ -284,6 +291,14 @@ export class Preferences extends React.Component<
this.props.dispatcher.setSelectedTheme(theme)
}

private onAutomaticallySwitchThemeChanged = (
automaticallySwitchTheme: boolean
) => {
this.props.dispatcher.onAutomaticallySwitchThemeChanged(
automaticallySwitchTheme
)
}

private renderFooter() {
const hasDisabledError = this.state.disallowedCharactersMessage != null

Expand Down
3 changes: 3 additions & 0 deletions app/test/__mocks__/electron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export const shell = {
}

export const remote = {
app: {
on: jest.fn(),
},
getCurrentWindow: jest.fn().mockImplementation(() => ({
isFullScreen: jest.fn().mockImplementation(() => true),
webContents: {
Expand Down