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
6 changes: 4 additions & 2 deletions src/components/theme-creator/ColorPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -444,9 +446,9 @@ export function ColorPicker({
padding: 0,
overflow: "hidden"
}}
title={value}
title={unset ? undefined : value}
>
<div style={{ width: "100%", height: "100%", background: value }} />
{!unset && <div style={{ width: "100%", height: "100%", background: value }} />}
</button>
{open && anchorRect && (
<Popover
Expand Down
166 changes: 104 additions & 62 deletions src/components/theme-creator/ThemeCreator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
MAX_TERMINAL_LINE_HEIGHT,
MIN_TERMINAL_LINE_HEIGHT,
} from "@/utils/terminalTheme";
import { getUiGroups, getTerminalGroups, getFieldLabels } from "./colorGroups";
import { getUiGroups, getTerminalGroups, getFieldLabels, OPTIONAL_TERMINAL_FIELDS } from "./colorGroups";
import { ColorPicker } from "./ColorPicker";
import { primaryFamily, toFontStack, useSystemFonts } from "@/utils/systemFonts";
import { PickerSurface } from "@/components/shared/PickerSurface";
Expand Down Expand Up @@ -312,6 +312,65 @@ function VarSearchOverlay({ varName, onClose }: { varName: string; onClose: () =

// ── ColorEditor ───────────────────────────────────────────────────────────────

const ROW_STYLE: React.CSSProperties = { padding: "2px 4px", margin: "0 -4px" };
const PICKED_ROW_STYLE: React.CSSProperties = {
...ROW_STYLE,
borderRadius: 4,
boxShadow: "inset 0 0 0 1px var(--t-accent)",
background: "color-mix(in srgb, var(--t-accent) 12%, transparent)",
};

function RowIconButton({ icon, title, active = false, onClick }: {
icon: string;
title: string;
active?: boolean;
onClick: () => void;
}) {
return (
<button
onClick={onClick}
title={title}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 18, height: 18, borderRadius: 4, border: "1px solid",
cursor: "pointer", flexShrink: 0,
borderColor: active ? "var(--t-accent)" : "transparent",
background: active ? "color-mix(in srgb, var(--t-accent) 18%, transparent)" : "transparent",
color: active ? "var(--t-accent)" : "var(--t-text-dim)",
}}
onMouseEnter={(e) => { if (!active) e.currentTarget.style.color = "var(--t-text-muted)"; }}
onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = "var(--t-text-dim)"; }}
>
<Icon icon={icon} width={10} />
</button>
);
}

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 (
<div
data-field={field}
data-picked={picked ? "true" : undefined}
className="flex items-center gap-2 transition-all duration-300"
style={picked ? PICKED_ROW_STYLE : ROW_STYLE}
>
<ColorPicker value={color} unset={unset} onChange={onChange} />
<span className="text-xs flex-1 text-(--t-text-secondary)">{label}</span>
<code className="text-xs font-mono text-(--t-text-muted)">{text}</code>
{children}
</div>
);
}

function ColorEditor({
draft,
setDraft,
Expand All @@ -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<string | null>(null);

const ui = draft.ui as unknown as Record<string, string>;
const term = draft.terminal as unknown as Record<string, string>;
const scrollRef = useRef<HTMLDivElement>(null);

// Scroll to first match when pickedFields changes
Expand All @@ -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 && <VarSearchOverlay varName={searchVar} onClose={() => setSearchVar(null)} />}
Expand Down Expand Up @@ -397,38 +443,27 @@ function ColorEditor({
{uiGroups.map((group) => (
<div key={group.label} className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-widest text-(--t-text-dim)">{group.label}</p>
{group.fields.map((field) => (
<div
key={field}
data-field={field}
data-picked={pickedFields.has(field as string) ? "true" : undefined}
className="flex items-center gap-2 transition-all duration-300"
style={rowStyle(field as string)}
>
<ColorPicker
value={ui[field as string]}
{group.fields.map((field) => {
const varName = fieldToVar(field);
return (
<ColorRow
key={field}
field={field}
label={fieldLabels[field] ?? field}
picked={pickedFields.has(field)}
color={draft.ui[field]}
text={draft.ui[field]}
onChange={(hex) => setUiColor(field, hex)}
/>
<span className="text-xs flex-1 text-(--t-text-secondary)">{fieldLabels[field] ?? field}</span>
<code className="text-xs font-mono text-(--t-text-muted)">{ui[field as string]}</code>
<button
onClick={() => setSearchVar(searchVar === fieldToVar(field as string) ? null : fieldToVar(field as string))}
title={t("themeCreator.editor.highlightUsing", { varName: fieldToVar(field as string) })}
style={{
display: "flex", alignItems: "center", justifyContent: "center",
width: 18, height: 18, borderRadius: 4, border: "1px solid",
cursor: "pointer", flexShrink: 0,
borderColor: searchVar === fieldToVar(field as string) ? "var(--t-accent)" : "transparent",
background: searchVar === fieldToVar(field as string) ? "color-mix(in srgb, var(--t-accent) 18%, transparent)" : "transparent",
color: searchVar === fieldToVar(field as string) ? "var(--t-accent)" : "var(--t-text-dim)",
}}
onMouseEnter={(e) => { if (searchVar !== fieldToVar(field as string)) (e.currentTarget as HTMLButtonElement).style.color = "var(--t-text-muted)"; }}
onMouseLeave={(e) => { if (searchVar !== fieldToVar(field as string)) (e.currentTarget as HTMLButtonElement).style.color = "var(--t-text-dim)"; }}
>
<Icon icon="lucide:search" width={10} />
</button>
</div>
))}
<RowIconButton
icon="lucide:search"
title={t("themeCreator.editor.highlightUsing", { varName })}
active={searchVar === varName}
onClick={() => setSearchVar(searchVar === varName ? null : varName)}
/>
</ColorRow>
);
})}
</div>
))}

Expand Down Expand Up @@ -475,23 +510,30 @@ function ColorEditor({
{terminalGroups.map((group) => (
<div key={group.label} className="space-y-1.5">
<p className="text-xs font-bold uppercase tracking-widest text-(--t-text-dim)">{group.label}</p>
{group.fields.map((field) => (
<div
key={field}
data-field={field}
data-picked={pickedFields.has(field as string) ? "true" : undefined}
className="flex items-center gap-2 transition-all duration-300"
style={rowStyle(field as string)}
>
<ColorPicker
value={term[field as string].startsWith("#") && term[field as string].length >= 7
? term[field as string].slice(0, 7) : "#000000"}
onChange={(hex) => setTermColor(field, hex)}
/>
<span className="text-xs flex-1 text-(--t-text-secondary)">{fieldLabels[field] ?? field}</span>
<code className="text-xs font-mono text-(--t-text-muted)">{term[field as string].slice(0, 7)}</code>
</div>
))}
{group.fields.map((field) => {
const value = draft.terminal[field];
const hex = value?.startsWith("#") && value.length >= 7 ? value.slice(0, 7) : undefined;
return (
<ColorRow
key={field}
field={field}
label={fieldLabels[field] ?? field}
picked={pickedFields.has(field)}
color={hex ?? (value ? "#000000" : draft.terminal.foreground)}
text={value?.slice(0, 7) ?? "—"}
unset={!value}
onChange={(next) => setTermColor(field, next)}
>
{value && OPTIONAL_TERMINAL_FIELDS.has(field) && (
<RowIconButton
icon="lucide:x"
title={t("themeCreator.editor.resetColor")}
onClick={() => setTermColor(field, undefined)}
/>
)}
</ColorRow>
);
})}
</div>
))}
</div>
Expand Down
5 changes: 4 additions & 1 deletion src/components/theme-creator/colorGroups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ export function getUiGroups(t: TFunction): { label: string; fields: (keyof UIThe
];
}

export const OPTIONAL_TERMINAL_FIELDS: ReadonlySet<keyof TerminalTheme> = 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"),
Expand Down Expand Up @@ -63,6 +65,7 @@ export function getFieldLabels(t: TFunction): Record<string, string> {
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"),
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/cs/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -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í",
Expand Down Expand Up @@ -84,6 +85,7 @@
"foreground": "Popředí",
"cursor": "Kurzor",
"selectionBackground": "Výběr",
"selectionForeground": "Vybraný text",
"black": "Černá",
"red": "Červená",
"green": "Zelená",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/en/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -82,6 +83,7 @@
"foreground": "Foreground",
"cursor": "Cursor",
"selectionBackground": "Selection",
"selectionForeground": "Selected Text",
"black": "Black",
"red": "Red",
"green": "Green",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/fr/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -83,6 +84,7 @@
"foreground": "Premier plan",
"cursor": "Curseur",
"selectionBackground": "Sélection",
"selectionForeground": "Texte sélectionné",
"black": "Noir",
"red": "Rouge",
"green": "Vert",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/ru/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"sizePx": "Размер (px)",
"lineHeight": "Межстрочный интервал",
"terminalFont": "Шрифт терминала",
"highlightUsing": "Подсветить элементы, использующие {{varName}}"
"highlightUsing": "Подсветить элементы, использующие {{varName}}",
"resetColor": "Сбросить"
},
"groups": {
"backgrounds": "Фоны",
Expand Down Expand Up @@ -84,6 +85,7 @@
"foreground": "Передний план",
"cursor": "Курсор",
"selectionBackground": "Выделение",
"selectionForeground": "Выделенный текст",
"black": "Чёрный",
"red": "Красный",
"green": "Зелёный",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/tr/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/locales/zh/themeCreator.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@
"sizePx": "大小(px)",
"lineHeight": "行高",
"terminalFont": "终端字体",
"highlightUsing": "高亮使用 {{varName}} 的元素"
"highlightUsing": "高亮使用 {{varName}} 的元素",
"resetColor": "恢复默认"
},
"groups": {
"backgrounds": "背景",
Expand Down Expand Up @@ -81,6 +82,7 @@
"foreground": "前景",
"cursor": "光标",
"selectionBackground": "选择",
"selectionForeground": "选中文本",
"black": "黑色",
"red": "红色",
"green": "绿色",
Expand Down
2 changes: 2 additions & 0 deletions src/themes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions src/utils/terminalColors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
Loading
Loading