From 88226ced3c99ef421165b8ea28aba07d6c67752f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 13:24:29 +0000 Subject: [PATCH] feat(theme): selected text colour as a theme field A theme could set the selection background but not the colour of the text inside it, so selected text always kept its own colours. Themes now take an optional selectionForeground, passed to xterm with the rest of the terminal palette. Leaving it unset keeps today's behaviour, so existing themes and the built-in presets are unchanged. The theme creator shows it as "Selected Text" under Terminal Base. An unset field shows an empty swatch; once a colour is picked the row gets a reset button. The colour sent to tmux for copy mode follows the field too. The creator's UI and terminal colour rows were two copies of the same markup. They now share one row component and one icon button. Refs #317 --- src/components/theme-creator/ColorPicker.tsx | 6 +- src/components/theme-creator/ThemeCreator.tsx | 166 +++++++++++------- src/components/theme-creator/colorGroups.ts | 5 +- src/i18n/locales/cs/themeCreator.json | 4 +- src/i18n/locales/en/themeCreator.json | 4 +- src/i18n/locales/fr/themeCreator.json | 4 +- src/i18n/locales/ru/themeCreator.json | 4 +- src/i18n/locales/tr/themeCreator.json | 4 +- src/i18n/locales/zh/themeCreator.json | 4 +- src/themes/types.ts | 2 + src/utils/terminalColors.test.ts | 11 ++ src/utils/terminalColors.ts | 3 +- 12 files changed, 145 insertions(+), 72 deletions(-) diff --git a/src/components/theme-creator/ColorPicker.tsx b/src/components/theme-creator/ColorPicker.tsx index 8bf2be105..e45a46dd7 100644 --- a/src/components/theme-creator/ColorPicker.tsx +++ b/src/components/theme-creator/ColorPicker.tsx @@ -385,9 +385,11 @@ function Popover({ export function ColorPicker({ value, onChange, + unset = false, }: { value: string; onChange: (hex: string) => void; + unset?: boolean; }) { const [open, setOpen] = useState(false); const [hsva, setHsvaRaw] = useState<[number, number, number, number]>(() => hexToHsva(value)); @@ -444,9 +446,9 @@ export function ColorPicker({ padding: 0, overflow: "hidden" }} - title={value} + title={unset ? undefined : value} > -
+ {!unset &&
} {open && anchorRect && ( void; +}) { + return ( + + ); +} + +function ColorRow({ field, label, picked, color, text, unset, onChange, children }: { + field: string; + label: string; + picked: boolean; + color: string; + text: string; + unset?: boolean; + onChange: (hex: string) => void; + children?: React.ReactNode; +}) { + return ( +
+ + {label} + {text} + {children} +
+ ); +} + function ColorEditor({ draft, setDraft, @@ -328,13 +387,11 @@ function ColorEditor({ const setUiColor = (field: keyof UITheme, value: string) => setDraft((d) => ({ ...d, ui: { ...d.ui, [field]: value } })); - const setTermColor = (field: keyof TerminalTheme, value: string) => + const setTermColor = (field: keyof TerminalTheme, value: string | undefined) => setDraft((d) => ({ ...d, terminal: { ...d.terminal, [field]: value } })); const [searchVar, setSearchVar] = useState(null); - const ui = draft.ui as unknown as Record; - const term = draft.terminal as unknown as Record; const scrollRef = useRef(null); // Scroll to first match when pickedFields changes @@ -344,17 +401,6 @@ function ColorEditor({ first?.scrollIntoView({ behavior: "smooth", block: "center" }); }, [pickedFields]); - const rowStyle = (field: string): React.CSSProperties => - pickedFields.has(field) - ? { - borderRadius: 4, - boxShadow: "inset 0 0 0 1px var(--t-accent)", - background: "color-mix(in srgb, var(--t-accent) 12%, transparent)", - padding: "2px 4px", - margin: "0 -4px", - } - : { padding: "2px 4px", margin: "0 -4px" }; - return ( <> {searchVar && setSearchVar(null)} />} @@ -397,38 +443,27 @@ function ColorEditor({ {uiGroups.map((group) => (

{group.label}

- {group.fields.map((field) => ( -
- { + const varName = fieldToVar(field); + return ( + setUiColor(field, hex)} - /> - {fieldLabels[field] ?? field} - {ui[field as string]} - -
- ))} + setSearchVar(searchVar === varName ? null : varName)} + /> + + ); + })}
))} @@ -475,23 +510,30 @@ function ColorEditor({ {terminalGroups.map((group) => (

{group.label}

- {group.fields.map((field) => ( -
- = 7 - ? term[field as string].slice(0, 7) : "#000000"} - onChange={(hex) => setTermColor(field, hex)} - /> - {fieldLabels[field] ?? field} - {term[field as string].slice(0, 7)} -
- ))} + {group.fields.map((field) => { + const value = draft.terminal[field]; + const hex = value?.startsWith("#") && value.length >= 7 ? value.slice(0, 7) : undefined; + return ( + setTermColor(field, next)} + > + {value && OPTIONAL_TERMINAL_FIELDS.has(field) && ( + setTermColor(field, undefined)} + /> + )} + + ); + })}
))}
diff --git a/src/components/theme-creator/colorGroups.ts b/src/components/theme-creator/colorGroups.ts index 8b73aef48..18f2996fa 100644 --- a/src/components/theme-creator/colorGroups.ts +++ b/src/components/theme-creator/colorGroups.ts @@ -31,11 +31,13 @@ export function getUiGroups(t: TFunction): { label: string; fields: (keyof UIThe ]; } +export const OPTIONAL_TERMINAL_FIELDS: ReadonlySet = new Set(["selectionForeground"]); + export function getTerminalGroups(t: TFunction): { label: string; fields: (keyof TerminalTheme)[] }[] { return [ { label: t("themeCreator.groups.terminalBase"), - fields: ["background", "foreground", "cursor", "selectionBackground"], + fields: ["background", "foreground", "cursor", "selectionBackground", "selectionForeground"], }, { label: t("themeCreator.groups.ansiColors"), @@ -63,6 +65,7 @@ export function getFieldLabels(t: TFunction): Record { textNotice: t("themeCreator.fields.textNotice"), background: t("themeCreator.fields.background"), foreground: t("themeCreator.fields.foreground"), cursor: t("themeCreator.fields.cursor"), selectionBackground: t("themeCreator.fields.selectionBackground"), + selectionForeground: t("themeCreator.fields.selectionForeground"), black: t("themeCreator.fields.black"), red: t("themeCreator.fields.red"), green: t("themeCreator.fields.green"), yellow: t("themeCreator.fields.yellow"), blue: t("themeCreator.fields.blue"), magenta: t("themeCreator.fields.magenta"), cyan: t("themeCreator.fields.cyan"), white: t("themeCreator.fields.white"), brightBlack: t("themeCreator.fields.brightBlack"), brightRed: t("themeCreator.fields.brightRed"), brightGreen: t("themeCreator.fields.brightGreen"), diff --git a/src/i18n/locales/cs/themeCreator.json b/src/i18n/locales/cs/themeCreator.json index 5d1568fff..81b477b93 100644 --- a/src/i18n/locales/cs/themeCreator.json +++ b/src/i18n/locales/cs/themeCreator.json @@ -34,7 +34,8 @@ "sizePx": "Velikost (px)", "lineHeight": "Výška řádku", "terminalFont": "Písmo terminálu", - "highlightUsing": "Zvýraznit prvky používající {{varName}}" + "highlightUsing": "Zvýraznit prvky používající {{varName}}", + "resetColor": "Obnovit výchozí" }, "groups": { "backgrounds": "Pozadí", @@ -84,6 +85,7 @@ "foreground": "Popředí", "cursor": "Kurzor", "selectionBackground": "Výběr", + "selectionForeground": "Vybraný text", "black": "Černá", "red": "Červená", "green": "Zelená", diff --git a/src/i18n/locales/en/themeCreator.json b/src/i18n/locales/en/themeCreator.json index 27e80a271..e63e48a59 100644 --- a/src/i18n/locales/en/themeCreator.json +++ b/src/i18n/locales/en/themeCreator.json @@ -32,7 +32,8 @@ "sizePx": "Size (px)", "lineHeight": "Line height", "terminalFont": "Terminal Font", - "highlightUsing": "Highlight elements using {{varName}}" + "highlightUsing": "Highlight elements using {{varName}}", + "resetColor": "Reset to default" }, "groups": { "backgrounds": "Backgrounds", @@ -82,6 +83,7 @@ "foreground": "Foreground", "cursor": "Cursor", "selectionBackground": "Selection", + "selectionForeground": "Selected Text", "black": "Black", "red": "Red", "green": "Green", diff --git a/src/i18n/locales/fr/themeCreator.json b/src/i18n/locales/fr/themeCreator.json index 43d1bf0ad..87261f977 100644 --- a/src/i18n/locales/fr/themeCreator.json +++ b/src/i18n/locales/fr/themeCreator.json @@ -33,7 +33,8 @@ "sizePx": "Taille (px)", "lineHeight": "Hauteur de ligne", "terminalFont": "Police du terminal", - "highlightUsing": "Mettre en surbrillance les éléments utilisant {{varName}}" + "highlightUsing": "Mettre en surbrillance les éléments utilisant {{varName}}", + "resetColor": "Réinitialiser" }, "groups": { "backgrounds": "Arrière-plans", @@ -83,6 +84,7 @@ "foreground": "Premier plan", "cursor": "Curseur", "selectionBackground": "Sélection", + "selectionForeground": "Texte sélectionné", "black": "Noir", "red": "Rouge", "green": "Vert", diff --git a/src/i18n/locales/ru/themeCreator.json b/src/i18n/locales/ru/themeCreator.json index 5bc19a6f7..ee8f0a7d5 100644 --- a/src/i18n/locales/ru/themeCreator.json +++ b/src/i18n/locales/ru/themeCreator.json @@ -34,7 +34,8 @@ "sizePx": "Размер (px)", "lineHeight": "Межстрочный интервал", "terminalFont": "Шрифт терминала", - "highlightUsing": "Подсветить элементы, использующие {{varName}}" + "highlightUsing": "Подсветить элементы, использующие {{varName}}", + "resetColor": "Сбросить" }, "groups": { "backgrounds": "Фоны", @@ -84,6 +85,7 @@ "foreground": "Передний план", "cursor": "Курсор", "selectionBackground": "Выделение", + "selectionForeground": "Выделенный текст", "black": "Чёрный", "red": "Красный", "green": "Зелёный", diff --git a/src/i18n/locales/tr/themeCreator.json b/src/i18n/locales/tr/themeCreator.json index 23ccc2007..9ea6ee1a7 100644 --- a/src/i18n/locales/tr/themeCreator.json +++ b/src/i18n/locales/tr/themeCreator.json @@ -32,7 +32,8 @@ "sizePx": "Boyut (px)", "lineHeight": "Satır yüksekliği", "terminalFont": "Terminal yazı tipi", - "highlightUsing": "{{varName}} kullanan öğeleri vurgula" + "highlightUsing": "{{varName}} kullanan öğeleri vurgula", + "resetColor": "Varsayılana sıfırla" }, "groups": { "backgrounds": "Arka planlar", @@ -82,6 +83,7 @@ "foreground": "Ön plan", "cursor": "İmleç", "selectionBackground": "Seçim", + "selectionForeground": "Seçili Metin", "black": "Siyah", "red": "Kırmızı", "green": "Yeşil", diff --git a/src/i18n/locales/zh/themeCreator.json b/src/i18n/locales/zh/themeCreator.json index de71cf986..da449aa0d 100644 --- a/src/i18n/locales/zh/themeCreator.json +++ b/src/i18n/locales/zh/themeCreator.json @@ -31,7 +31,8 @@ "sizePx": "大小(px)", "lineHeight": "行高", "terminalFont": "终端字体", - "highlightUsing": "高亮使用 {{varName}} 的元素" + "highlightUsing": "高亮使用 {{varName}} 的元素", + "resetColor": "恢复默认" }, "groups": { "backgrounds": "背景", @@ -81,6 +82,7 @@ "foreground": "前景", "cursor": "光标", "selectionBackground": "选择", + "selectionForeground": "选中文本", "black": "黑色", "red": "红色", "green": "绿色", diff --git a/src/themes/types.ts b/src/themes/types.ts index fc467c3e6..2944e9827 100644 --- a/src/themes/types.ts +++ b/src/themes/types.ts @@ -3,6 +3,8 @@ export interface TerminalTheme { foreground: string; cursor: string; selectionBackground: string; + /** Unset: selected text keeps its own colours. */ + selectionForeground?: string; black: string; red: string; green: string; yellow: string; blue: string; magenta: string; cyan: string; white: string; brightBlack: string; brightRed: string; brightGreen: string; diff --git a/src/utils/terminalColors.test.ts b/src/utils/terminalColors.test.ts index f6654c858..200a0bbec 100644 --- a/src/utils/terminalColors.test.ts +++ b/src/utils/terminalColors.test.ts @@ -22,6 +22,17 @@ describe("terminalColorsFor", () => { }); }); + it("uses the theme's selected-text colour when it sets one", () => { + expect(terminalColorsFor(theme({ selectionForeground: "#21b568" }))).toMatchObject({ + fg: "#e2e8f0", + selectionFg: "#21b568", + }); + }); + + it("falls back to the foreground when the selected-text colour is unparseable", () => { + expect(terminalColorsFor(theme({ selectionForeground: "green" }))).toMatchObject({ selectionFg: "#e2e8f0" }); + }); + it("gives up on colours it cannot parse", () => { expect(terminalColorsFor(theme({ background: "rgb(1, 2, 3)" }))).toBeNull(); }); diff --git a/src/utils/terminalColors.ts b/src/utils/terminalColors.ts index b04d11d24..7ba615643 100644 --- a/src/utils/terminalColors.ts +++ b/src/utils/terminalColors.ts @@ -25,8 +25,9 @@ export function terminalColorsFor(theme: TerminalTheme): TerminalColors | null { const bg = parseHex(theme.background); const sel = parseHex(theme.selectionBackground); if (!fg || !bg || !sel) return null; + const selFg = (theme.selectionForeground && parseHex(theme.selectionForeground)) || fg; const blended = sel.rgb.map((c, i) => c * sel.alpha + bg.rgb[i] * (1 - sel.alpha)); - return { fg: toHex(fg.rgb), bg: toHex(bg.rgb), selectionFg: toHex(fg.rgb), selectionBg: toHex(blended) }; + return { fg: toHex(fg.rgb), bg: toHex(bg.rgb), selectionFg: toHex(selFg.rgb), selectionBg: toHex(blended) }; } export function currentTerminalColors(): TerminalColors | null {