Skip to content
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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The app's settings are in one dialog, opened from the cog at the bottom right of

## General

- **Theme** — light, dark, or whatever your operating system is set to, which is the default. A change applies as you make it, to the whole window; nothing running is touched.
- **Theme** — light, dark, whatever your operating system is set to, which is the default, or custom: a background and a primary colour of your own, typed as hex or picked from the swatch, from which the app builds every other colour. A change applies as you make it, to the whole window; nothing running is touched. The dialog says if some text may be hard to read on the colours you chose; they are kept all the same.
- **Language** — which language the app is shown in: your system's language, which is the default, or one of the languages the app has a translation for. A change applies after a relaunch, which the dialog offers; relaunching stops running servers and builds, as quitting does. Translations come from [translate.wordpress.org](https://translate.wordpress.org/projects/meta/contributor-toolkit/) and ship with the app once they are mostly complete, so the list grows from release to release.
- **Start the server when I open a site** and **Start the build watch when I open a site** — off unless you turn them on. On, opening a site that is set up starts its [development server](./running-the-site), its build watch, or both, as it would if you pressed Start; on WordPress Core the server's start brings the watch with it. Nothing starts for a site whose setup, update or deletion is under way, and nothing already running is started again.
- **When I quit, running servers and build watches** — quitting always stops them, as it always has. **Stop them, and start them again next time** remembers which sites had a server or a watch running and starts them again when the app next opens, whichever site it opens on.
Expand Down
46 changes: 28 additions & 18 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const { addFilter } = require('@wordpress/hooks');
const { mergeInProgressError, mergeCheckFailedError } = require('./renderer/merge-in-progress.cjs');
const { parseHandle } = require('./wporg-handle.cjs');
const { SETTINGS, readSettings, acceptSetting } = require('./settings.cjs');
const { windowBackground } = require('./theme.cjs');
const { resolveTheme, nativeThemeSource, THEME_KEYS } = require('./theme.cjs');
const { parseEventName, buildProvenanceHeader, handoffFilename } = require('./patch-provenance.cjs');
const { describeRefused } = require('./safe-log');
const { detectEditors, matchDetectedEditor, openSiteInEditor, REFUSAL_REASONS } = require('./editor-launch');
Expand Down Expand Up @@ -564,7 +564,7 @@ function createWindow() {
icon: process.platform === 'linux' ? path.join(__dirname, '..', 'build', 'icon.png') : undefined,
// The colour of the theme the window is made in (#560), so that a dark
// window is not white for the moment before its page has painted.
backgroundColor: windowBackground(nativeTheme.shouldUseDarkColors),
backgroundColor: currentTheme().background,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
Expand Down Expand Up @@ -715,28 +715,41 @@ function localeReply() {
}

// The theme (#560), given to Electron. `nativeTheme` is the one place the
// choice is made: Chromium answers the page's `prefers-color-scheme` from it,
// and paints the window's chrome and the native form controls to match, so
// the page only has to follow what it is told, as it would the operating
// system's. Read from the store before the window is made, so the window is
// made in it; a store that cannot be read leaves the system's theme, with a
// line in the log, as it leaves the system's language.
// scheme is decided: Chromium answers the page's `prefers-color-scheme` from
// it, and paints the window's chrome and the native form controls to match,
// so the page only has to follow what it is told, as it would the operating
// system's. A custom theme is given as the scheme its background comes to.
// Read from the store before the window is made, so the window is made in
// it; a store that cannot be read leaves the system's theme, with a line in
// the log, as it leaves the system's language.
//
// `themeSettings` is what the theme was last applied from, for the colour a
// window is made with: the system's theme until the store is read.
let themeSettings = { theme: 'system' };
function applyTheme(settings) {
themeSettings = settings;
nativeTheme.themeSource = nativeThemeSource(settings);
paintWindowForTheme();
}
function currentTheme() {
return resolveTheme({ ...themeSettings, systemDark: nativeTheme.shouldUseDarkColors });
}
async function applyStoredTheme() {
try {
nativeTheme.themeSource = readSettings((await getStore()).get('preferences')).theme;
applyTheme(readSettings((await getStore()).get('preferences')));
} catch (e) {
logError('theme', `the settings could not be read, so the theme is the system's: ${String(e && e.message ? e.message : e)}`);
// A deep link can have opened the window while the store was read.
paintWindowForTheme();
}
// A deep link can have opened the window while the store was read.
paintWindowForTheme();
}

// The colour the window was made with shows wherever the page has not
// painted yet (a live resize, a reload), so it is given again whenever the
// theme is: by the setting, here and in `settings:set`, and by Electron's
// theme is: by the setting, through `applyTheme`, and by Electron's
// `updated`, which is how the system's theme reaches it under 'system'.
function paintWindowForTheme() {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setBackgroundColor(windowBackground(nativeTheme.shouldUseDarkColors));
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setBackgroundColor(currentTheme().background);
}

// The languages the settings offer: what the build ships, read once.
Expand Down Expand Up @@ -2438,7 +2451,7 @@ ipcMain.handle('trac:list-attachments', async (_e, sitePath) => {
if (projectTypeForSite(meta).workItem.provider !== 'trac') return { ok: true, status: 'not-trac', items: [] };
const ticketId = meta.tracTicket;
if (!ticketId) return { ok: true, status: 'no-ticket', items: [] };
const result = await openAndScrape(ticketId);
const result = await openAndScrape(ticketId, { backgroundColor: currentTheme().background });
return { ok: true, ...result };
} catch (e) {
logError('trac:list-attachments', String(e && e.stack ? e.stack : e));
Expand Down Expand Up @@ -3580,10 +3593,7 @@ ipcMain.handle('settings:set', async (_e, key, value) => {
// The theme applies at once (#560): Electron tells the page, and the
// window's own colour is set here rather than left to Electron's
// `updated`, which is not promised for a change to 'system'.
if (key === 'theme') {
nativeTheme.themeSource = settings.theme;
paintWindowForTheme();
}
if (THEME_KEYS.includes(key)) applyTheme(settings);
return { ok: true, settings };
});

Expand Down
61 changes: 43 additions & 18 deletions src/renderer/components/app-theme.jsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { ThemeProvider } from '@wordpress/theme';
import { themeColorSeeds } from '../../theme.cjs';
import { resolveTheme } from '../../theme.cjs';

// What Chromium says of the window's colour scheme. It says it from the theme
// main gave Electron (#560), or from the operating system under 'system', so
// this is the one thing the window reads: not the setting, which would be a
// second answer to the same question.
// the scheme is read here and not decided again from the setting: for the
// three named themes it is the answer, and for a custom one the setting's
// colours are what is painted, in the scheme main decided from them.
const DARK_SCHEME = '(prefers-color-scheme: dark)';

// Whether the window is in the dark scheme, for whatever paints with values
// rather than with a stylesheet (the terminal) and so has to be told when it
// changes.
const DarkSchemeContext = createContext(false);
// The theme as painted, for whatever paints with values rather than with a
// stylesheet (the terminal) and so has to be told when it changes: a name
// that changes with it, and what the design system said of the colours.
const ThemeContext = createContext({ key: 'light', warnings: [] });

function usePrefersDark() {
const [dark, setDark] = useState(() => window.matchMedia(DARK_SCHEME).matches);
Expand All @@ -30,18 +31,42 @@ function usePrefersDark() {
// it overrides on the document rather than on its own wrapper, which is what
// reaches a modal or a popover: those are portalled to `body`, outside this
// tree. In the light scheme it is given no colour, so the tokens stylesheet's
// values stand as they ship; in the dark scheme it is given the dark seed and
// builds every colour token from it, for its own components, for the older
// ones through the variables they read, and for the app's own styles.
export function AppTheme({ children }) {
const dark = usePrefersDark();
// values stand as they ship; in the dark scheme it is given the dark seed, and
// under a custom theme the two colours chosen, and builds every colour token
// from them, for its own components, for the older ones through the variables
// they read, and for the app's own styles.
//
// `settings` is what main holds, or null until it has answered: until then
// the window is painted as under 'system', for the scheme alone, which for a
// custom theme is the standard theme of its scheme for the moment before
// the colours arrive.
export function AppTheme({ settings, children }) {
const prefersDark = usePrefersDark();
const [warnings, setWarnings] = useState([]);
const theme = settings ? settings.theme : 'system';
const customBackground = settings ? settings.customBackground : undefined;
const customPrimary = settings ? settings.customPrimary : undefined;
const resolved = useMemo(
() => resolveTheme({ theme, customBackground, customPrimary, systemDark: prefersDark }),
[theme, customBackground, customPrimary, prefersDark]
);
const value = useMemo(() => ({ key: resolved.key, warnings }), [resolved.key, warnings]);
return (
<DarkSchemeContext.Provider value={dark}>
<ThemeProvider isRoot color={themeColorSeeds(dark)}>{children}</ThemeProvider>
</DarkSchemeContext.Provider>
<ThemeContext.Provider value={value}>
<ThemeProvider isRoot color={resolved.seeds} onColorWarnings={setWarnings}>{children}</ThemeProvider>
</ThemeContext.Provider>
);
}

export function useDarkScheme() {
return useContext(DarkSchemeContext);
// A name for the theme as painted, which changes whenever what is painted
// does.
export function useThemeKey() {
return useContext(ThemeContext).key;
}

// What the design system said of the colours it was given: a contrast it
// could not reach is one, so that a custom theme can say when its text may
// be hard to read.
export function useThemeWarnings() {
return useContext(ThemeContext).warnings;
}
100 changes: 88 additions & 12 deletions src/renderer/components/settings-dialog.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { useEffect, useId, useMemo, useState } from 'react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
// The segmented control the design has for a choice of a few. The design
// system has no other, and documents this one under these names: it is
// stable in use and has not been given its final export yet.
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis -- see above.
import { __experimentalToggleGroupControl as ToggleGroupControl, __experimentalToggleGroupControlOption as ToggleGroupControlOption } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { Button, Dialog, InputControl, Notice, SelectControl, Stack, SwitchControl, Tabs, Text } from '@wordpress/ui';
import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, themeItems, SYSTEM_LANGUAGE } from '../settings-view.cjs';
import { __, sprintf } from '@wordpress/i18n';
import { Button, Dialog, InputControl, InputLayout, Notice, SelectControl, Stack, SwitchControl, Tabs, Text } from '@wordpress/ui';
import { useThemeWarnings } from './app-theme.jsx';
import { githubAccountLine, newSiteLocationNote, languageItems, languageValue, languageChanged, phpVersionChoice, quitItems, themeItems, colorFieldDraft, pickerValue, SYSTEM_LANGUAGE } from '../settings-view.cjs';
import { FolderField } from './folder-field.jsx';

// A notice here is read by its role, and is not also spoken: the dialog it
Expand Down Expand Up @@ -81,32 +82,107 @@ function LanguageControl({ settings, loaded, onChange }) {
);
}

// The window's theme (#560): light, dark, or the operating system's. Main
// gives the choice to Electron, and the window follows what Chromium then
// says of the colour scheme, so the change is on screen as the control is
// pressed.
// One colour of the custom theme (#560): typed as hex, or picked with the
// system's picker, which is the swatch before the field, showing the colour
// being chosen, or the colour kept while the field holds no colour. What is
// typed is kept when the field is left or Enter is
// pressed, and main says what it accepts, so a colour that is not one is
// refused in main's words and the field goes back to what is kept. The
// picker's choice is kept when the picker is closed, not as it is dragged:
// each keep is a write to the store, and the field shows the colour under
// the pointer meanwhile. The picker is driven from the draft and not from
// what is kept: a controlled input is put back to its prop after every
// step of a drag, and the picker's closing would then read the old colour.
function ColorField({ label, value, onKeep }) {
const [draft, setDraft] = useState(value);
useEffect(() => setDraft(value), [value]);
// What the field shows once main has answered is settings-view's to say:
// what is kept after a refusal, the colour as kept after a keep, and what
// has been typed since where the answer is to an older draft.
const keep = async (text) => {
const result = await onKeep(text);
setDraft((current) => colorFieldDraft({ current, sent: text, ok: Boolean(result?.ok), kept: value }));
};
const picker = useRef(null);
useEffect(() => {
const input = picker.current;
if (!input) return undefined;
const picked = () => keep(input.value);
input.addEventListener('change', picked);
return () => input.removeEventListener('change', picked);
});
const commit = () => { if (draft !== value) keep(draft); };
return (
<InputControl
className="color-field"
type="text"
label={label}
value={draft}
spellCheck={false}
autoComplete="off"
prefix={
<InputLayout.Slot padding="minimal">
<input
ref={picker}
type="color"
className="color-swatch"
// translators: %s: what the colour is for, "Background" or "Primary".
aria-label={sprintf(__('%s colour picker'), label)}
value={pickerValue(draft, value)}
onChange={(event) => setDraft(event.currentTarget.value)}
/>
</InputLayout.Slot>
}
onChange={(event) => setDraft(event.currentTarget.value)}
onBlur={commit}
onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault(); commit(); } }}
/>
);
}

// The window's theme (#560): light, dark, the operating system's, or custom,
// a background and a primary colour of the contributor's own, from which
// the design system builds every other colour. Main gives the choice to
// Electron, and the window follows what Chromium then says of the colour
// scheme and what main holds, so the change is on screen as the control is
// pressed. Under custom, the design system says when a colour it built
// cannot be read on another, and the control passes that on.
function ThemeControl({ settings, onChange }) {
const [error, setError] = useState('');
const keep = async (value) => {
const result = await onChange('theme', value);
const warnings = useThemeWarnings();
const keep = async (key, value) => {
const result = await onChange(key, value);
setError(result?.ok ? '' : (result?.error || __('Could not keep that.')));
return result;
};
const custom = settings?.theme === 'custom';
return (
<>
<ToggleGroupControl
__nextHasNoMarginBottom
__next40pxDefaultSize
isBlock
label={__('Theme')}
help={__('System follows your operating system’s light or dark setting.')}
help={__('System follows your operating system’s light or dark setting. Custom builds the theme from two colours of your own.')}
value={settings ? settings.theme : undefined}
disabled={!settings}
onChange={(value) => { if (value) keep(value); }}
onChange={(value) => { if (value) keep('theme', value); }}
>
{themeItems().map((item) => (
<ToggleGroupControlOption key={item.value} value={item.value} label={item.label} />
))}
</ToggleGroupControl>
{custom ? (
<div className="theme-colors">
<ColorField label={__('Background')} value={settings.customBackground} onKeep={(value) => keep('customBackground', value)} />
<ColorField label={__('Primary')} value={settings.customPrimary} onKeep={(value) => keep('customPrimary', value)} />
</div>
) : null}
{custom && warnings.length ? (
<Notice.Root intent="warning" role="status" spokenMessage={SILENT}>
<Notice.Description>{__('Some text may be hard to read with these colours. They are kept as they are; choose others if it is.')}</Notice.Description>
</Notice.Root>
) : null}
{error ? (
<Notice.Root intent="error" role="alert" spokenMessage={SILENT}>
<Notice.Description>{error}</Notice.Description>
Expand Down
8 changes: 4 additions & 4 deletions src/renderer/hooks/use-site-terminal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react
import { Terminal } from '@xterm/xterm';
import { terminalFont, terminalTheme, tokenName, TERMINAL_READABILITY } from '../terminal-theme.cjs';
import { terminalGrid } from '../tray.cjs';
import { useDarkScheme } from '../components/app-theme.jsx';
import { useThemeKey } from '../components/app-theme.jsx';

// What the terminal is painted with, read off the design system's tokens
// where the terminal stands (#557). The terminal takes its colours and its
Expand Down Expand Up @@ -59,7 +59,7 @@ const TERMINAL_INSTALL_ALIASES = ['npm install', 'npm i', 'install'];
// None of them depends on the three runners, which may change as often as
// they like.
export function useSiteTerminal({ allowedScripts, runInstall, runScript, killCurrent, shown }) {
const dark = useDarkScheme();
const themeKey = useThemeKey();
// Read through a ref by the terminal's command handlers rather than closed
// over: the xterm instance is created by an effect that depends on
// `printHelp`, so a new array identity here would otherwise dispose and
Expand Down Expand Up @@ -384,7 +384,7 @@ export function useSiteTerminal({ allowedScripts, runInstall, runScript, killCur
fitTerminal();
});

// Painted again when the window's scheme changes (#560). The terminal was
// Painted again when the window's theme changes (#560). The terminal was
// given its colours as values when it opened, and a change to the tokens
// does not reach a value; so they are read again, after the provider has
// put the new tokens on the document, which it does in a layout effect,
Expand All @@ -393,7 +393,7 @@ export function useSiteTerminal({ allowedScripts, runInstall, runScript, killCur
const term = terminalRef.current;
if (!term || !term.element || !container) return;
term.options.theme = readTerminalLook(container).theme;
}, [dark, container]);
}, [themeKey, container]);

// Fitted again whenever its element changes size: the tray dragged, the
// window resized, and the element coming back on screen, which is a change
Expand Down
Loading
Loading