Connect: Add theme configuration (#27788)

* Add a config item for the theme and adjust Electron's `nativeTheme` based on that

* Listen to theme changes and update the app accordingly

* React to theme changes in teleterm stories

* Rename channel

* Return boolean from handlers

* Do not mock the whole app context in storybook

* Fix linting issue

* Fix typo
This commit is contained in:
Grzegorz Zdunek 2023-06-16 11:28:21 +02:00 committed by GitHub
parent e23e6b6082
commit 95e6482043
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 136 additions and 53 deletions

View file

@ -21,8 +21,11 @@ import { darkTheme, lightTheme } from './../packages/design/src/theme';
import DefaultThemeProvider from '../packages/design/src/ThemeProvider';
import Box from './../packages/design/src/Box';
import '../packages/teleport/src/lib/polyfillRandomUuid';
import { ThemeProvider as TeletermThemeProvider } from './../packages/teleterm/src/ui/ThemeProvider';
import { theme as TeletermTheme } from './../packages/teleterm/src/ui/ThemeProvider/theme';
import { StaticThemeProvider as TeletermThemeProvider } from './../packages/teleterm/src/ui/ThemeProvider';
import {
darkTheme as teletermDarkTheme,
lightTheme as teletermLightTheme,
} from './../packages/teleterm/src/ui/ThemeProvider/theme';
import { handlersTeleport } from './../packages/teleport/src/mocks/handlers';
// Checks we are running non-node environment (browser)
@ -41,7 +44,10 @@ const ThemeDecorator = (storyFn, meta) => {
if (meta.title.startsWith('Teleterm/')) {
ThemeProvider = TeletermThemeProvider;
theme = TeletermTheme;
theme =
meta.globals.theme === 'Dark Theme'
? teletermDarkTheme
: teletermLightTheme;
} else {
ThemeProvider = DefaultThemeProvider;
theme = meta.globals.theme === 'Dark Theme' ? darkTheme : lightTheme;

View file

@ -18,7 +18,7 @@ import { spawn } from 'child_process';
import path from 'path';
import { app, globalShortcut, shell } from 'electron';
import { app, globalShortcut, shell, nativeTheme } from 'electron';
import MainProcess from 'teleterm/mainProcess';
import { getRuntimeSettings } from 'teleterm/mainProcess/runtimeSettings';
@ -59,6 +59,8 @@ async function initializeApp(): Promise<void> {
jsonSchemaFile: configJsonSchemaFileStorage,
platform: settings.platform,
});
nativeTheme.themeSource = configService.get('theme').value;
const windowsManager = new WindowsManager(appStateFileStorage, settings);
process.on('uncaughtException', (error, origin) => {

View file

@ -68,6 +68,14 @@ export class MockMainProcessClient implements MainProcessClient {
async openConfigFile() {
return '';
}
shouldUseDarkColors() {
return true;
}
subscribeToNativeThemeUpdate() {
return { cleanup: () => undefined };
}
}
export const makeRuntimeSettings = (

View file

@ -26,6 +26,7 @@ import {
ipcMain,
Menu,
MenuItemConstructorOptions,
nativeTheme,
shell,
} from 'electron';
import { wait } from 'shared/utils/wait';
@ -193,6 +194,10 @@ export default class MainProcess {
event.returnValue = this.settings;
});
ipcMain.on('main-process-should-use-dark-colors', event => {
event.returnValue = nativeTheme.shouldUseDarkColors;
});
ipcMain.handle('main-process-get-resolved-child-process-addresses', () => {
return this.resolvedChildProcessAddresses;
});

View file

@ -53,8 +53,20 @@ export default function createMainProcessClient(): MainProcessClient {
removeTshSymlinkMacOs() {
return ipcRenderer.invoke('main-process-remove-tsh-symlink-macos');
},
openConfigFile(): Promise<string> {
openConfigFile() {
return ipcRenderer.invoke('main-process-open-config-file');
},
shouldUseDarkColors() {
return ipcRenderer.sendSync('main-process-should-use-dark-colors');
},
subscribeToNativeThemeUpdate: listener => {
const onThemeChange = (_, value: { shouldUseDarkColors: boolean }) =>
listener(value);
const channel = 'main-process-native-theme-update';
ipcRenderer.addListener(channel, onThemeChange);
return {
cleanup: () => ipcRenderer.removeListener(channel, onThemeChange),
};
},
};
}

View file

@ -75,6 +75,13 @@ export type MainProcessClient = {
/** Opens config file and returns a path to it. */
openConfigFile(): Promise<string>;
shouldUseDarkColors(): boolean;
/** Subscribes to updates of the native theme. Returns a cleanup function. */
subscribeToNativeThemeUpdate: (
listener: (value: { shouldUseDarkColors: boolean }) => void
) => {
cleanup: () => void;
};
};
export type ChildProcessAddresses = {

View file

@ -16,11 +16,18 @@
import path from 'path';
import { app, BrowserWindow, Menu, Rectangle, screen } from 'electron';
import {
app,
BrowserWindow,
Menu,
Rectangle,
screen,
nativeTheme,
} from 'electron';
import { FileStorage } from 'teleterm/services/fileStorage';
import { RuntimeSettings } from 'teleterm/mainProcess/types';
import theme from 'teleterm/ui/ThemeProvider/theme';
import { darkTheme, lightTheme } from 'teleterm/ui/ThemeProvider/theme';
type WindowState = Rectangle;
@ -47,13 +54,16 @@ export class WindowsManager {
}
createWindow(): void {
const activeTheme = nativeTheme.shouldUseDarkColors
? darkTheme
: lightTheme;
const windowState = this.getWindowState();
const window = new BrowserWindow({
x: windowState.x,
y: windowState.y,
width: windowState.width,
height: windowState.height,
backgroundColor: theme.colors.levels.sunken,
backgroundColor: activeTheme.colors.levels.sunken,
minWidth: 400,
minHeight: 300,
show: false,
@ -88,6 +98,12 @@ export class WindowsManager {
this.popupUniversalContextMenu(window, props);
});
nativeTheme.on('updated', () => {
window.webContents.send('main-process-native-theme-update', {
shouldUseDarkColors: nativeTheme.shouldUseDarkColors,
});
});
window.webContents.session.setPermissionRequestHandler(
(webContents, permission, callback) => {
// deny all permissions requests, we currently do not require any

View file

@ -106,6 +106,10 @@ export const createAppConfigSchema = (platform: Platform) => {
.max(256)
.default(15)
.describe('Font size for the terminal.'),
theme: z
.enum(['light', 'dark', 'system'])
.default('system')
.describe('Color theme for the app.'),
});
};

View file

@ -15,7 +15,7 @@ limitations under the License.
*/
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import styled, { useTheme } from 'styled-components';
import { Box, Flex } from 'design';
import { debounce } from 'shared/utils/highbar';
import {
@ -54,6 +54,7 @@ export function Terminal(props: TerminalProps) {
const [startPtyProcessAttempt, setStartPtyProcessAttempt] = useState<
Attempt<void>
>(makeEmptyAttempt());
const theme = useTheme();
useEffect(() => {
const removeOnStartErrorListener = props.ptyProcess.onStartError(
@ -69,6 +70,7 @@ export function Terminal(props: TerminalProps) {
const ctrl = new XTermCtrl(props.ptyProcess, {
el: refElement.current,
fontSize: props.fontSize,
theme: theme.colors.terminal,
});
// Start the PTY process.
@ -103,6 +105,12 @@ export function Terminal(props: TerminalProps) {
refCtrl.current.requestResize();
}, [props.visible]);
useEffect(() => {
if (refCtrl.current) {
refCtrl.current.term.options.theme = theme.colors.terminal;
}
}, [theme]);
return (
<Flex
flexDirection="column"

View file

@ -15,19 +15,19 @@ limitations under the License.
*/
import 'xterm/css/xterm.css';
import { IDisposable, Terminal } from 'xterm';
import { IDisposable, ITheme, Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import { debounce } from 'shared/utils/highbar';
import { IPtyProcess } from 'teleterm/sharedProcess/ptyHost';
import Logger from 'teleterm/logger';
import theme from 'teleterm/ui/ThemeProvider/theme';
const WINDOW_RESIZE_DEBOUNCE_DELAY = 200;
type Options = {
el: HTMLElement;
fontSize: number;
theme: ITheme;
};
export default class TtyTerminal {
@ -62,29 +62,7 @@ export default class TtyTerminal {
fontSize: this.options.fontSize,
scrollback: 5000,
minimumContrastRatio: 4.5, // minimum for WCAG AA compliance
theme: {
foreground: theme.colors.terminal.foreground,
background: theme.colors.terminal.background,
selectionBackground: theme.colors.terminal.selectionBackground,
cursor: theme.colors.terminal.cursor,
cursorAccent: theme.colors.terminal.cursorAccent,
red: theme.colors.terminal.red,
green: theme.colors.terminal.green,
yellow: theme.colors.terminal.yellow,
blue: theme.colors.terminal.blue,
magenta: theme.colors.terminal.magenta,
cyan: theme.colors.terminal.cyan,
brightWhite: theme.colors.terminal.brightWhite,
white: theme.colors.terminal.white,
brightBlack: theme.colors.terminal.brightBlack,
black: theme.colors.terminal.black,
brightRed: theme.colors.terminal.brightRed,
brightGreen: theme.colors.terminal.brightGreen,
brightYellow: theme.colors.terminal.brightYellow,
brightBlue: theme.colors.terminal.brightBlue,
brightMagenta: theme.colors.terminal.brightMagenta,
brightCyan: theme.colors.terminal.brightCyan,
},
theme: this.options.theme,
windowOptions: {
setWinSizeChars: true,
},

View file

@ -14,22 +14,62 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import {
ThemeProvider as StyledThemeProvider,
StyleSheetManager,
} from 'styled-components';
import { GlobalStyle } from './globals';
import theme from './theme';
import { useAppContext } from 'teleterm/ui/appContextProvider';
export const ThemeProvider: React.FC = props => (
<StyledThemeProvider theme={theme}>
<StyleSheetManager disableVendorPrefixes>
<React.Fragment>
<GlobalStyle />
{props.children}
</React.Fragment>
</StyleSheetManager>
</StyledThemeProvider>
);
import { GlobalStyle } from './globals';
import { darkTheme, lightTheme } from './theme';
export const ThemeProvider = (props: React.PropsWithChildren<unknown>) => {
// Listening to Electron's nativeTheme.on('updated') is a workaround.
// The renderer should be able to get the current theme via "prefers-color-scheme" media query.
// Unfortunately, it does not work correctly on Ubuntu where the query from above always returns the old value
// (for example, when the app was launched in a dark mode, it always returns 'dark'
// ignoring that the system theme is now 'light').
// Related Electron issue: https://github.com/electron/electron/issues/21427#issuecomment-589796481,
// Related Chromium issue: https://bugs.chromium.org/p/chromium/issues/detail?id=998903
//
// Additional issue is that nativeTheme does not return correct values at all on Fedora:
// https://github.com/electron/electron/issues/33635#issuecomment-1502215450
const ctx = useAppContext();
const [activeTheme, setActiveTheme] = useState(() =>
ctx.mainProcessClient.shouldUseDarkColors() ? darkTheme : lightTheme
);
useEffect(() => {
const { cleanup } = ctx.mainProcessClient.subscribeToNativeThemeUpdate(
({ shouldUseDarkColors }) => {
setActiveTheme(shouldUseDarkColors ? darkTheme : lightTheme);
}
);
return cleanup;
}, [ctx.mainProcessClient]);
return (
<StaticThemeProvider theme={activeTheme}>
{props.children}
</StaticThemeProvider>
);
};
/** Uses a theme from a prop. Useful in storybook. */
export const StaticThemeProvider = (
props: React.PropsWithChildren<{ theme?: unknown }>
) => {
return (
<StyledThemeProvider theme={props.theme}>
<StyleSheetManager disableVendorPrefixes>
<React.Fragment>
<GlobalStyle />
{props.children}
</React.Fragment>
</StyleSheetManager>
</StyledThemeProvider>
);
};

View file

@ -14,4 +14,4 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
export { ThemeProvider } from './ThemeProvider';
export * from './ThemeProvider';

View file

@ -23,7 +23,7 @@ import { lighten } from 'design/theme/utils/colorManipulator';
const sansSerif = 'system-ui';
const darkTheme = {
export const darkTheme = {
...designDarkTheme,
colors: {
...designDarkTheme.colors,
@ -43,8 +43,7 @@ const darkTheme = {
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const lightTheme = {
export const lightTheme = {
...designLightTheme,
font: sansSerif,
fonts: {
@ -52,5 +51,3 @@ const lightTheme = {
mono: fonts.mono,
},
};
export default darkTheme;