feat(config): reorganize settings window and move annotation colors to subtitleStyle

- Reorganize Configuration window into Appearance, Behavior, Anki, Input, and Integration sections
- Add AnkiConnect-backed deck, note-type, and field pickers in the Anki section
- Add click-to-learn keybinding controls
- Move known-word and N+1 highlight colors to subtitleStyle.knownWordColor / subtitleStyle.nPlusOneColor; legacy ankiConnect.knownWords.color and ankiConnect.nPlusOne.nPlusOne keys still accepted with deprecation warnings
- Add deckNames, modelNames, modelFieldNames, and fieldNamesForDeck methods to AnkiConnectClient
- Mark discordPresence.presenceStyle as an enum in the config registry
This commit is contained in:
2026-05-17 02:10:16 -07:00
parent 799cce6991
commit 0298a066ad
44 changed files with 2152 additions and 321 deletions
+102
View File
@@ -0,0 +1,102 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { Keybinding } from '../types/runtime';
import {
buildMpvKeybindingConfigValue,
createMpvKeybindingRows,
keyboardEventToConfigKey,
} from './key-input';
test('keyboardEventToConfigKey formats Electron accelerators from learned input', () => {
assert.equal(
keyboardEventToConfigKey(
{ code: 'KeyS', key: 's', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
'accelerator',
),
'CommandOrControl+Shift+S',
);
assert.equal(
keyboardEventToConfigKey(
{ code: 'Slash', key: '/', ctrlKey: false, altKey: true, shiftKey: false, metaKey: false },
'accelerator',
),
'Alt+Slash',
);
});
test('keyboardEventToConfigKey formats DOM code bindings from learned input', () => {
assert.equal(
keyboardEventToConfigKey(
{ code: 'KeyJ', key: 'j', ctrlKey: true, altKey: false, shiftKey: true, metaKey: false },
'dom-code',
),
'Ctrl+Shift+KeyJ',
);
assert.equal(
keyboardEventToConfigKey(
{
code: 'Backquote',
key: '`',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
},
'dom-code',
),
'Backquote',
);
});
test('keyboardEventToConfigKey formats bare key-code fields without modifiers', () => {
assert.equal(
keyboardEventToConfigKey(
{ code: 'KeyW', key: 'w', ctrlKey: true, altKey: true, shiftKey: false, metaKey: false },
'code',
),
'KeyW',
);
});
test('MPV keybinding rows save default key moves as a disable plus replacement', () => {
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
const rows = createMpvKeybindingRows(defaults, []);
rows[0]!.key = 'KeyP';
assert.deepEqual(buildMpvKeybindingConfigValue(defaults, rows), [
{ key: 'Space', command: null },
{ key: 'KeyP', command: ['cycle', 'pause'] },
]);
});
test('MPV keybinding rows reopen moved default bindings as their default row', () => {
const defaults: Keybinding[] = [{ key: 'Space', command: ['cycle', 'pause'] }];
assert.deepEqual(
createMpvKeybindingRows(defaults, [
{ key: 'Space', command: null },
{ key: 'KeyP', command: ['cycle', 'pause'] },
]),
[
{
defaultKey: 'Space',
key: 'KeyP',
command: ['cycle', 'pause'],
commandText: '["cycle","pause"]',
isDefault: true,
},
],
);
});
test('MPV keybinding rows omit unchanged default bindings from config value', () => {
const defaults: Keybinding[] = [
{ key: 'Space', command: ['cycle', 'pause'] },
{ key: 'KeyF', command: ['cycle', 'fullscreen'] },
];
assert.deepEqual(
buildMpvKeybindingConfigValue(defaults, createMpvKeybindingRows(defaults, [])),
[],
);
});
+217
View File
@@ -0,0 +1,217 @@
import type { Keybinding } from '../types/runtime';
export type KeyInputMode = 'accelerator' | 'dom-code' | 'code';
export interface KeyboardInputLike {
code: string;
key: string;
ctrlKey: boolean;
altKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
export interface MpvKeybindingRow {
defaultKey: string;
key: string;
command: (string | number)[] | null;
commandText: string;
isDefault: boolean;
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
'ControlLeft',
'ControlRight',
'MetaLeft',
'MetaRight',
'ShiftLeft',
'ShiftRight',
]);
const ELECTRON_KEY_BY_CODE: Record<string, string> = {
Backquote: 'Backquote',
Backslash: 'Backslash',
BracketLeft: 'BracketLeft',
BracketRight: 'BracketRight',
Comma: 'Comma',
Delete: 'Delete',
End: 'End',
Enter: 'Enter',
Equal: 'Plus',
Escape: 'Escape',
Home: 'Home',
Insert: 'Insert',
Minus: 'Minus',
PageDown: 'PageDown',
PageUp: 'PageUp',
Period: 'Period',
Quote: 'Quote',
Semicolon: 'Semicolon',
Slash: 'Slash',
Space: 'Space',
Tab: 'Tab',
};
function commandEquals(a: Keybinding['command'], b: Keybinding['command']): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
function normalizeUserBindings(userBindings: unknown): Keybinding[] {
if (!Array.isArray(userBindings)) return [];
return userBindings.filter((binding): binding is Keybinding => {
if (!binding || typeof binding !== 'object') return false;
const candidate = binding as Keybinding;
return (
typeof candidate.key === 'string' &&
(candidate.command === null || Array.isArray(candidate.command))
);
});
}
function electronKeyToken(input: KeyboardInputLike): string | null {
if (/^Key[A-Z]$/.test(input.code)) return input.code.slice(3);
if (/^Digit[0-9]$/.test(input.code)) return input.code.slice(5);
if (/^Numpad[0-9]$/.test(input.code)) return `num${input.code.slice(6)}`;
if (/^F\d{1,2}$/.test(input.code)) return input.code;
if (input.code.startsWith('Arrow')) return input.code.replace('Arrow', '');
return ELECTRON_KEY_BY_CODE[input.code] ?? null;
}
export function keyboardEventToConfigKey(
input: KeyboardInputLike,
mode: KeyInputMode,
): string | null {
if (!input.code || MODIFIER_CODES.has(input.code)) {
return null;
}
if (mode === 'code') {
return input.code;
}
const parts: string[] = [];
if (mode === 'accelerator') {
if (input.ctrlKey || input.metaKey) parts.push('CommandOrControl');
if (input.altKey) parts.push('Alt');
if (input.shiftKey) parts.push('Shift');
const key = electronKeyToken(input);
return key ? [...parts, key].join('+') : null;
}
if (input.ctrlKey) parts.push('Ctrl');
if (input.altKey) parts.push('Alt');
if (input.shiftKey) parts.push('Shift');
if (input.metaKey) parts.push('Meta');
return [...parts, input.code].join('+');
}
export function createMpvKeybindingRows(
defaultBindings: Keybinding[],
userBindings: unknown,
): MpvKeybindingRow[] {
const normalizedUserBindings = normalizeUserBindings(userBindings);
const userByKey = new Map(normalizedUserBindings.map((binding) => [binding.key, binding]));
const consumedUserKeys = new Set<string>();
const rows = defaultBindings.map((binding) => {
const override = userByKey.get(binding.key);
if (override?.command === null) {
const movedOverride = normalizedUserBindings.find(
(candidate) =>
candidate.key !== binding.key && commandEquals(candidate.command, binding.command),
);
if (movedOverride) {
consumedUserKeys.add(binding.key);
consumedUserKeys.add(movedOverride.key);
return {
defaultKey: binding.key,
key: movedOverride.key,
command: movedOverride.command,
commandText: JSON.stringify(movedOverride.command ?? null),
isDefault: true,
};
}
}
if (override) {
consumedUserKeys.add(binding.key);
}
const command = override?.command ?? binding.command;
return {
defaultKey: binding.key,
key: binding.key,
command,
commandText: JSON.stringify(command ?? null),
isDefault: true,
};
});
for (const binding of normalizedUserBindings) {
if (consumedUserKeys.has(binding.key)) {
continue;
}
if (defaultBindings.some((defaultBinding) => defaultBinding.key === binding.key)) {
continue;
}
rows.push({
defaultKey: binding.key,
key: binding.key,
command: binding.command,
commandText: JSON.stringify(binding.command ?? null),
isDefault: false,
});
}
return rows;
}
export function parseMpvCommandText(value: string): Keybinding['command'] | undefined {
try {
const parsed = JSON.parse(value);
if (parsed === null) return null;
if (
Array.isArray(parsed) &&
parsed.every((entry) => typeof entry === 'string' || typeof entry === 'number')
) {
return parsed;
}
} catch {
return undefined;
}
return undefined;
}
export function buildMpvKeybindingConfigValue(
defaultBindings: Keybinding[],
rows: MpvKeybindingRow[],
): Keybinding[] {
const next: Keybinding[] = [];
for (const defaultBinding of defaultBindings) {
const row = rows.find((candidate) => candidate.defaultKey === defaultBinding.key);
if (!row) {
next.push({ key: defaultBinding.key, command: null });
continue;
}
if (row.key !== defaultBinding.key) {
next.push({ key: defaultBinding.key, command: null });
if (row.command !== null) {
next.push({ key: row.key, command: row.command });
}
continue;
}
if (!commandEquals(row.command, defaultBinding.command)) {
next.push({ key: row.key, command: row.command });
}
}
for (const row of rows.filter((candidate) => !candidate.isDefault)) {
next.push({ key: row.key, command: row.command });
}
return next;
}
+473
View File
@@ -0,0 +1,473 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
import type { SettingsControlContext } from './settings-control-context';
import { addOption, createElement, uniqueSorted } from './settings-control-dom';
const state: {
deckNames: string[] | null;
deckNamesLoading: boolean;
deckNamesError: string | null;
deckFieldNames: Map<string, string[]>;
deckFieldNamesLoading: Set<string>;
deckFieldNamesErrors: Map<string, string>;
modelNames: string[] | null;
modelNamesLoading: boolean;
modelNamesError: string | null;
modelFieldNames: Map<string, string[]>;
modelFieldNamesLoading: Set<string>;
modelFieldNamesErrors: Map<string, string>;
noteFieldModelName: string;
ankiConnectUrl: string;
} = {
deckNames: null,
deckNamesLoading: false,
deckNamesError: null,
deckFieldNames: new Map(),
deckFieldNamesLoading: new Set(),
deckFieldNamesErrors: new Map(),
modelNames: null,
modelNamesLoading: false,
modelNamesError: null,
modelFieldNames: new Map(),
modelFieldNamesLoading: new Set(),
modelFieldNamesErrors: new Map(),
noteFieldModelName: '',
ankiConnectUrl: '',
};
let requestRender = (): void => undefined;
export function configureAnkiControls(options: { requestRender: () => void }): void {
requestRender = options.requestRender;
}
export function initializeAnkiControls(values: Record<string, ConfigSettingsSnapshotValue>): void {
const configuredNoteType = values['ankiConnect.isLapis.sentenceCardModel'];
if (!state.noteFieldModelName && typeof configuredNoteType === 'string') {
state.noteFieldModelName = configuredNoteType;
}
}
function normalizeStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
: [];
}
function normalizeKnownWordsDecks(value: unknown): Record<string, string[]> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const decks: Record<string, string[]> = {};
for (const [deckName, fields] of Object.entries(value)) {
if (!deckName) continue;
decks[deckName] = normalizeStringArray(fields);
}
return decks;
}
function setKnownWordsDecks(
context: SettingsControlContext,
path: string,
decks: Record<string, string[]>,
): void {
const next: Record<string, string[]> = {};
for (const [deckName, fields] of Object.entries(decks)) {
if (!deckName) continue;
next[deckName] = uniqueSorted(fields);
}
context.updateDraft(path, next);
}
function getDraftAnkiConnectUrl(context: SettingsControlContext): string | undefined {
const value = context.valueForPath('ankiConnect.url');
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function syncAnkiConnectUrl(draftUrl: string | undefined): void {
const nextUrl = draftUrl ?? '';
if (state.ankiConnectUrl === nextUrl) {
return;
}
state.ankiConnectUrl = nextUrl;
state.deckNames = null;
state.deckNamesLoading = false;
state.deckNamesError = null;
state.deckFieldNames.clear();
state.deckFieldNamesLoading.clear();
state.deckFieldNamesErrors.clear();
state.modelNames = null;
state.modelNamesLoading = false;
state.modelNamesError = null;
state.modelFieldNames.clear();
state.modelFieldNamesLoading.clear();
state.modelFieldNamesErrors.clear();
}
async function loadAnkiDeckNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.deckNames || state.deckNamesLoading) return;
state.deckNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiDeckNames(draftUrl);
if (result.ok) {
state.deckNames = uniqueSorted(result.values);
state.deckNamesError = null;
} else {
state.deckNames = [];
state.deckNamesError = result.error ?? 'Failed to load Anki decks.';
}
} catch (error) {
state.deckNames = [];
state.deckNamesError = error instanceof Error ? error.message : 'Failed to load Anki decks.';
} finally {
state.deckNamesLoading = false;
requestRender();
}
}
async function loadAnkiDeckFieldNames(deckName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (
!deckName ||
state.deckFieldNames.has(deckName) ||
state.deckFieldNamesLoading.has(deckName)
) {
return;
}
state.deckFieldNamesLoading.add(deckName);
try {
const result = await window.configSettingsAPI.getAnkiDeckFieldNames(deckName, draftUrl);
if (result.ok) {
state.deckFieldNames.set(deckName, uniqueSorted(result.values));
state.deckFieldNamesErrors.delete(deckName);
} else {
state.deckFieldNames.set(deckName, []);
state.deckFieldNamesErrors.set(
deckName,
result.error ?? `Failed to load fields for ${deckName}.`,
);
}
} catch (error) {
state.deckFieldNames.set(deckName, []);
state.deckFieldNamesErrors.set(
deckName,
error instanceof Error ? error.message : `Failed to load fields for ${deckName}.`,
);
} finally {
state.deckFieldNamesLoading.delete(deckName);
requestRender();
}
}
async function loadAnkiModelNames(draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (state.modelNames || state.modelNamesLoading) return;
state.modelNamesLoading = true;
try {
const result = await window.configSettingsAPI.getAnkiModelNames(draftUrl);
if (result.ok) {
state.modelNames = uniqueSorted(result.values);
state.modelNamesError = null;
if (!state.noteFieldModelName && state.modelNames[0]) {
state.noteFieldModelName = state.modelNames[0];
}
} else {
state.modelNames = [];
state.modelNamesError = result.error ?? 'Failed to load Anki note types.';
}
} catch (error) {
state.modelNames = [];
state.modelNamesError =
error instanceof Error ? error.message : 'Failed to load Anki note types.';
} finally {
state.modelNamesLoading = false;
requestRender();
}
}
async function loadAnkiModelFieldNames(modelName: string, draftUrl?: string): Promise<void> {
syncAnkiConnectUrl(draftUrl);
if (
!modelName ||
state.modelFieldNames.has(modelName) ||
state.modelFieldNamesLoading.has(modelName)
) {
return;
}
state.modelFieldNamesLoading.add(modelName);
try {
const result = await window.configSettingsAPI.getAnkiModelFieldNames(modelName, draftUrl);
if (result.ok) {
state.modelFieldNames.set(modelName, uniqueSorted(result.values));
state.modelFieldNamesErrors.delete(modelName);
} else {
state.modelFieldNames.set(modelName, []);
state.modelFieldNamesErrors.set(
modelName,
result.error ?? `Failed to load fields for ${modelName}.`,
);
}
} catch (error) {
state.modelFieldNames.set(modelName, []);
state.modelFieldNamesErrors.set(
modelName,
error instanceof Error ? error.message : `Failed to load fields for ${modelName}.`,
);
} finally {
state.modelFieldNamesLoading.delete(modelName);
requestRender();
}
}
export function renderAnkiNoteTypeInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
const currentValue = context.valueForField(field);
const current = typeof currentValue === 'string' ? currentValue : '';
const select = createElement('select', 'config-input') as HTMLSelectElement;
const modelNames = uniqueSorted([...(state.modelNames ?? []), current]);
if (state.modelNamesLoading && modelNames.length === 0) {
addOption(select, current, 'Loading Note Types...');
}
for (const modelName of modelNames) {
addOption(select, modelName);
}
select.value = current;
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
const wrap = createElement('div', 'stacked-control');
wrap.append(select);
if (state.modelNamesError) {
const hint = createElement('div', 'control-hint error');
hint.textContent = state.modelNamesError;
wrap.append(hint);
}
return wrap;
}
export function renderAnkiFieldInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
if (state.noteFieldModelName) {
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
}
const currentValue = context.valueForField(field);
const current = typeof currentValue === 'string' ? currentValue : '';
const availableFields = state.noteFieldModelName
? (state.modelFieldNames.get(state.noteFieldModelName) ?? [])
: [];
const select = createElement('select', 'config-input') as HTMLSelectElement;
if (!state.noteFieldModelName) {
addOption(select, current, 'Select Note Type First');
select.disabled = true;
} else if (state.modelFieldNamesLoading.has(state.noteFieldModelName)) {
addOption(select, current, current || 'Loading Fields...');
select.disabled = true;
} else {
for (const fieldName of uniqueSorted([...availableFields, current])) {
addOption(select, fieldName);
}
}
select.value = current;
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
const wrap = createElement('div', 'stacked-control');
wrap.append(select);
const error = state.modelFieldNamesErrors.get(state.noteFieldModelName);
if (error) {
const hint = createElement('div', 'control-hint error');
hint.textContent = error;
wrap.append(hint);
}
return wrap;
}
export function renderNoteFieldModelPicker(context: SettingsControlContext): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiModelNames(draftUrl);
if (state.noteFieldModelName) {
void loadAnkiModelFieldNames(state.noteFieldModelName, draftUrl);
}
const row = createElement('article', 'field-row helper-row');
const copy = createElement('div', 'field-copy');
const title = createElement('h3');
title.textContent = 'Note Type';
const description = createElement('p');
description.textContent =
'Choose a note type from AnkiConnect to populate the field dropdowns below.';
copy.append(title, description);
const control = createElement('div', 'field-control');
const select = createElement('select', 'config-input') as HTMLSelectElement;
const modelNames = state.modelNames ?? [];
if (state.modelNamesLoading && modelNames.length === 0) {
addOption(select, '', 'Loading Note Types...');
} else {
for (const modelName of modelNames) {
addOption(select, modelName);
}
}
select.value = state.noteFieldModelName;
select.addEventListener('change', () => {
state.noteFieldModelName = select.value;
requestRender();
});
control.append(select);
row.append(copy, control);
return row;
}
export function renderKnownWordsDecksInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const draftUrl = getDraftAnkiConnectUrl(context);
void loadAnkiDeckNames(draftUrl);
const currentDecks = normalizeKnownWordsDecks(context.valueForField(field));
const deckNames = state.deckNames ?? [];
const container = createElement('div', 'deck-field-editor');
const entries = Object.entries(currentDecks).sort(([left], [right]) => left.localeCompare(right));
if (entries.length === 0) {
const empty = createElement('div', 'control-hint');
empty.textContent = state.deckNamesLoading
? 'Loading Anki decks...'
: 'No known-word decks configured.';
container.append(empty);
}
for (const [deckName, selectedFields] of entries) {
if (deckName) {
void loadAnkiDeckFieldNames(deckName, draftUrl);
}
const row = createElement('div', 'deck-field-row');
const deckSelect = createElement('select', 'config-input') as HTMLSelectElement;
for (const candidateDeck of uniqueSorted([...deckNames, deckName])) {
addOption(deckSelect, candidateDeck);
}
deckSelect.value = deckName;
deckSelect.addEventListener('change', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
const fields = nextDecks[deckName] ?? [];
delete nextDecks[deckName];
nextDecks[deckSelect.value] = fields;
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
const availableFields = deckName ? (state.deckFieldNames.get(deckName) ?? []) : [];
const fieldNames = uniqueSorted([...availableFields, ...selectedFields]);
const fieldsWrap = createElement('div', 'deck-field-fields');
const fieldActions = createElement('div', 'deck-field-actions');
const checkboxList = createElement('div', 'field-checkbox-list');
const setSelectedFields = (fields: string[]): void => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
nextDecks[deckName] = fields;
setKnownWordsDecks(context, field.configPath, nextDecks);
};
const selectAllButton = createElement(
'button',
'secondary-button compact-button',
) as HTMLButtonElement;
selectAllButton.type = 'button';
selectAllButton.textContent = 'Select All';
selectAllButton.disabled = fieldNames.length === 0;
selectAllButton.addEventListener('click', () => {
setSelectedFields(fieldNames);
requestRender();
});
const clearButton = createElement(
'button',
'secondary-button compact-button',
) as HTMLButtonElement;
clearButton.type = 'button';
clearButton.textContent = 'Clear';
clearButton.disabled = selectedFields.length === 0;
clearButton.addEventListener('click', () => {
setSelectedFields([]);
requestRender();
});
fieldActions.append(selectAllButton, clearButton);
fieldsWrap.append(fieldActions, checkboxList);
if (state.deckFieldNamesLoading.has(deckName)) {
const hint = createElement('div', 'control-hint');
hint.textContent = 'Loading Fields...';
checkboxList.append(hint);
} else if (fieldNames.length === 0) {
const hint = createElement('div', 'control-hint');
hint.textContent = deckName ? 'No fields found for this deck.' : 'Select A Deck First.';
checkboxList.append(hint);
}
for (const candidateField of fieldNames) {
const label = createElement('label', 'field-checkbox-row');
const checkbox = createElement('input') as HTMLInputElement;
checkbox.type = 'checkbox';
checkbox.value = candidateField;
checkbox.checked = selectedFields.includes(candidateField);
checkbox.addEventListener('change', () => {
const checkedFields = [
...checkboxList.querySelectorAll<HTMLInputElement>('input[type="checkbox"]:checked'),
].map((input) => input.value);
setSelectedFields(checkedFields);
});
const text = createElement('span');
text.textContent = candidateField;
label.append(checkbox, text);
checkboxList.append(label);
}
const removeButton = createElement('button', 'reset-button icon-button') as HTMLButtonElement;
removeButton.type = 'button';
removeButton.textContent = 'Remove';
removeButton.addEventListener('click', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
delete nextDecks[deckName];
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
row.append(deckSelect, fieldsWrap, removeButton);
const error = state.deckFieldNamesErrors.get(deckName);
if (error) {
const hint = createElement('div', 'control-hint error');
hint.textContent = error;
row.append(hint);
}
container.append(row);
}
const addButton = createElement('button', 'secondary-button compact-button') as HTMLButtonElement;
addButton.type = 'button';
addButton.textContent = 'Add Deck';
addButton.disabled = deckNames.length === 0;
addButton.addEventListener('click', () => {
const nextDecks = normalizeKnownWordsDecks(context.valueForField(field));
const nextDeckName = deckNames.find((deckName) => !Object.hasOwn(nextDecks, deckName));
if (!nextDeckName) return;
nextDecks[nextDeckName] = [];
setKnownWordsDecks(context, field.configPath, nextDecks);
requestRender();
});
container.append(addButton);
if (state.deckNamesError) {
const hint = createElement('div', 'control-hint error');
hint.textContent = state.deckNamesError;
container.append(hint);
}
return container;
}
+8
View File
@@ -0,0 +1,8 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
export interface SettingsControlContext {
setFieldError(path: string, message: string | null): void;
updateDraft(path: string, value: ConfigSettingsSnapshotValue): void;
valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue;
valueForPath(path: string): ConfigSettingsSnapshotValue | undefined;
}
+34
View File
@@ -0,0 +1,34 @@
import type { ConfigSettingsSnapshotValue } from '../types/settings';
export function createElement<K extends keyof HTMLElementTagNameMap>(
tagName: K,
className?: string,
): HTMLElementTagNameMap[K] {
const element = document.createElement(tagName);
if (className) {
element.className = className;
}
return element;
}
export function addOption(select: HTMLSelectElement, value: string, label = value): void {
const option = createElement('option') as HTMLOptionElement;
option.value = value;
option.textContent = label;
select.append(option);
}
export function uniqueSorted(values: Iterable<string>): string[] {
return [...new Set([...values].filter(Boolean))].sort((a, b) => a.localeCompare(b));
}
export function isSecretSnapshotValue(
value: ConfigSettingsSnapshotValue,
): value is { configured: boolean } {
return Boolean(
value &&
typeof value === 'object' &&
'configured' in value &&
typeof (value as { configured?: unknown }).configured === 'boolean',
);
}
+202
View File
@@ -0,0 +1,202 @@
import type { ConfigSettingsField, ConfigSettingsSnapshotValue } from '../types/settings';
import { parseOptionalNumberInputValue } from './input-values';
import {
configureAnkiControls,
initializeAnkiControls,
renderAnkiFieldInput,
renderAnkiNoteTypeInput,
renderKnownWordsDecksInput,
renderNoteFieldModelPicker,
} from './settings-anki-controls';
import type { SettingsControlContext } from './settings-control-context';
import { createElement, isSecretSnapshotValue } from './settings-control-dom';
import { renderKeyboardInput, renderMpvKeybindingsInput } from './settings-keybinding-controls';
export { renderNoteFieldModelPicker };
export function configureSettingsControls(options: { requestRender: () => void }): void {
configureAnkiControls(options);
}
export function initializeSettingsControls(
values: Record<string, ConfigSettingsSnapshotValue>,
): void {
initializeAnkiControls(values);
}
function renderColorListInput(
context: SettingsControlContext,
field: ConfigSettingsField,
value: ConfigSettingsSnapshotValue,
): HTMLElement {
const colors = Array.isArray(value) ? (value as string[]) : [];
const container = createElement('div', 'color-list');
for (let i = 0; i < colors.length; i++) {
const row = createElement('div', 'color-list-row');
const label = createElement('span', 'color-list-label');
label.textContent = `Band ${i + 1}`;
const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = 'color';
input.value = colors[i] ?? '#000000';
input.addEventListener('input', () => {
const updated = [...colors];
updated[i] = input.value;
context.updateDraft(field.configPath, updated);
});
row.append(label, input);
container.append(row);
}
return container;
}
function renderJsonInput(
context: SettingsControlContext,
field: ConfigSettingsField,
value: ConfigSettingsSnapshotValue,
): HTMLElement {
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = JSON.stringify(value ?? {}, null, 2);
textarea.addEventListener('input', () => {
try {
context.updateDraft(field.configPath, JSON.parse(textarea.value));
textarea.classList.remove('invalid');
context.setFieldError(field.configPath, null);
} catch {
textarea.classList.add('invalid');
context.setFieldError(field.configPath, 'Invalid JSON');
}
});
return textarea;
}
function renderStringListInput(
context: SettingsControlContext,
field: ConfigSettingsField,
value: ConfigSettingsSnapshotValue,
): HTMLElement {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = Array.isArray(value) ? value.join('\n') : '';
textarea.addEventListener('input', () => {
context.updateDraft(
field.configPath,
textarea.value
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean),
);
});
return textarea;
}
export function renderControl(
field: ConfigSettingsField,
context: SettingsControlContext,
): HTMLElement {
const value = context.valueForField(field);
if (field.control === 'keyboard-shortcut') {
return renderKeyboardInput(context, field, 'accelerator');
}
if (field.control === 'key-code') {
return renderKeyboardInput(context, field, 'code');
}
if (field.control === 'known-words-decks') {
return renderKnownWordsDecksInput(context, field);
}
if (field.control === 'anki-note-type') {
return renderAnkiNoteTypeInput(context, field);
}
if (field.control === 'anki-field') {
return renderAnkiFieldInput(context, field);
}
if (field.control === 'mpv-keybindings') {
return renderMpvKeybindingsInput(context, field);
}
if (field.control === 'boolean') {
const label = createElement('label', 'switch-control');
const input = createElement('input') as HTMLInputElement;
input.type = 'checkbox';
input.checked = Boolean(value);
input.addEventListener('change', () => context.updateDraft(field.configPath, input.checked));
const track = createElement('span', 'switch-track');
label.append(input, track);
return label;
}
if (field.control === 'number') {
const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = 'number';
input.value = typeof value === 'number' ? String(value) : '';
input.addEventListener('input', () => {
const next = parseOptionalNumberInputValue(input.value);
if (next.ok) {
input.classList.remove('invalid');
context.setFieldError(field.configPath, null);
context.updateDraft(field.configPath, next.value);
} else {
input.classList.add('invalid');
context.setFieldError(field.configPath, 'Invalid number');
}
});
return input;
}
if (field.control === 'select') {
const select = createElement('select', 'config-input') as HTMLSelectElement;
for (const enumValue of field.enumValues ?? []) {
const option = createElement('option') as HTMLOptionElement;
option.value = enumValue;
option.textContent = enumValue;
option.selected = enumValue === value;
select.append(option);
}
select.addEventListener('change', () => context.updateDraft(field.configPath, select.value));
return select;
}
if (field.control === 'color-list') {
return renderColorListInput(context, field, value);
}
if (field.control === 'string-list') {
return renderStringListInput(context, field, value);
}
if (field.control === 'json') {
return renderJsonInput(context, field, value);
}
if (field.control === 'textarea') {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = typeof value === 'string' ? value : '';
textarea.addEventListener('input', () => context.updateDraft(field.configPath, textarea.value));
return textarea;
}
const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = field.control === 'secret' ? 'password' : field.control;
if (field.control === 'secret') {
input.placeholder =
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
input.addEventListener('input', () => {
if (input.value.trim().length === 0) {
context.updateDraft(field.configPath, value);
return;
}
context.updateDraft(field.configPath, input.value);
});
} else {
input.value = typeof value === 'string' ? value : '';
input.addEventListener('input', () => context.updateDraft(field.configPath, input.value));
}
return input;
}
@@ -0,0 +1,138 @@
import { DEFAULT_KEYBINDINGS } from '../config/definitions/shared';
import type { ConfigSettingsField } from '../types/settings';
import {
buildMpvKeybindingConfigValue,
createMpvKeybindingRows,
keyboardEventToConfigKey,
parseMpvCommandText,
type KeyInputMode,
type MpvKeybindingRow,
} from './key-input';
import type { SettingsControlContext } from './settings-control-context';
import { createElement } from './settings-control-dom';
let activeKeyLearningStop: (() => void) | null = null;
function startKeyLearning(
button: HTMLButtonElement,
mode: KeyInputMode,
onValue: (value: string) => void,
): void {
activeKeyLearningStop?.();
const previousText = button.textContent ?? '';
button.textContent = 'Press Keys...';
button.classList.add('learning');
let onKeyDown: (event: KeyboardEvent) => void;
let onBlur: () => void;
let onMouseDown: (event: MouseEvent) => void;
const stop = (): void => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('blur', onBlur, true);
window.removeEventListener('mousedown', onMouseDown, true);
button.classList.remove('learning');
if (button.textContent === 'Press Keys...') {
button.textContent = previousText;
}
if (activeKeyLearningStop === stop) {
activeKeyLearningStop = null;
}
};
onKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') {
stop();
return;
}
event.preventDefault();
event.stopPropagation();
const next = keyboardEventToConfigKey(event, mode);
if (!next) return;
stop();
onValue(next);
};
onBlur = (): void => stop();
onMouseDown = (event: MouseEvent): void => {
if (event.target !== button) {
stop();
}
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('blur', onBlur, true);
window.addEventListener('mousedown', onMouseDown, true);
activeKeyLearningStop = stop;
}
function renderKeyLearnButton(
value: string,
mode: KeyInputMode,
onValue: (value: string) => void,
): HTMLButtonElement {
const button = createElement('button', 'key-learn-button') as HTMLButtonElement;
button.type = 'button';
button.textContent = value || 'Unset';
button.addEventListener('click', () =>
startKeyLearning(button, mode, (next) => {
button.textContent = next;
onValue(next);
}),
);
return button;
}
export function renderKeyboardInput(
context: SettingsControlContext,
field: ConfigSettingsField,
mode: KeyInputMode,
): HTMLElement {
const value = context.valueForField(field);
return renderKeyLearnButton(typeof value === 'string' ? value : '', mode, (next) => {
context.updateDraft(field.configPath, next);
});
}
function applyMpvRows(
context: SettingsControlContext,
field: ConfigSettingsField,
rows: MpvKeybindingRow[],
): void {
context.updateDraft(field.configPath, buildMpvKeybindingConfigValue(DEFAULT_KEYBINDINGS, rows));
}
export function renderMpvKeybindingsInput(
context: SettingsControlContext,
field: ConfigSettingsField,
): HTMLElement {
const rows = createMpvKeybindingRows(DEFAULT_KEYBINDINGS, context.valueForField(field));
const container = createElement('div', 'keybinding-editor');
for (const row of rows) {
const item = createElement('div', 'keybinding-row');
const keyButton = renderKeyLearnButton(row.key, 'dom-code', (next) => {
row.key = next;
applyMpvRows(context, field, rows);
});
const command = createElement('input', 'config-input mono-input') as HTMLInputElement;
command.type = 'text';
command.value = row.commandText;
command.placeholder = '["cycle","pause"]';
command.addEventListener('input', () => {
const parsed = parseMpvCommandText(command.value);
if (parsed === undefined) {
command.classList.add('invalid');
context.setFieldError(field.configPath, 'Invalid MPV command JSON');
return;
}
command.classList.remove('invalid');
context.setFieldError(field.configPath, null);
row.command = parsed;
row.commandText = command.value;
applyMpvRows(context, field, rows);
});
item.append(keyButton, command);
container.append(item);
}
return container;
}
+4 -4
View File
@@ -14,8 +14,8 @@ const fields: ConfigSettingsField[] = [
label: 'Pause on subtitle hover',
description: 'Pause while hovering subtitles.',
configPath: 'subtitleStyle.autoPauseVideoOnHover',
category: 'viewing',
section: 'Playback pause behavior',
category: 'behavior',
section: 'Playback Pause Behavior',
control: 'boolean',
defaultValue: true,
restartBehavior: 'hot-reload',
@@ -35,12 +35,12 @@ const fields: ConfigSettingsField[] = [
test('filterSettingsFields searches label, section, and config path', () => {
assert.deepEqual(
filterSettingsFields(fields, { category: 'viewing', query: 'hover' }).map(
filterSettingsFields(fields, { category: 'behavior', query: 'hover' }).map(
(field) => field.configPath,
),
['subtitleStyle.autoPauseVideoOnHover'],
);
assert.deepEqual(filterSettingsFields(fields, { category: 'viewing', query: 'anki' }), []);
assert.deepEqual(filterSettingsFields(fields, { category: 'behavior', query: 'anki' }), []);
});
test('settings draft tracks dirty set and emits save operations', () => {
+1
View File
@@ -41,6 +41,7 @@ export function filterSettingsFields(
field.description,
field.configPath,
field.section,
field.subsection ?? '',
field.enumValues?.join(' ') ?? '',
]
.join(' ')
+68 -158
View File
@@ -6,7 +6,12 @@ import type {
ConfigSettingsSnapshot,
ConfigSettingsSnapshotValue,
} from '../types/settings';
import { parseOptionalNumberInputValue } from './input-values';
import {
configureSettingsControls,
initializeSettingsControls,
renderControl,
renderNoteFieldModelPicker,
} from './settings-controls';
import {
createSettingsDraft,
filterSettingsFields,
@@ -23,7 +28,8 @@ declare global {
}
const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
viewing: 'Viewing',
appearance: 'Appearance',
behavior: 'Behavior',
'mining-anki': 'Mining & Anki',
'playback-sources': 'Playback & Sources',
input: 'Input',
@@ -33,7 +39,8 @@ const CATEGORY_LABELS: Record<ConfigSettingsCategory, string> = {
};
const CATEGORY_ORDER: ConfigSettingsCategory[] = [
'viewing',
'appearance',
'behavior',
'mining-anki',
'playback-sources',
'input',
@@ -51,7 +58,7 @@ const state: {
} = {
snapshot: null,
draft: null,
category: 'viewing',
category: 'appearance',
query: '',
inputErrors: new Map(),
};
@@ -76,12 +83,6 @@ const dom = {
settingsContent: getElement<HTMLElement>('settingsContent'),
};
function isSecretSnapshotValue(
value: ConfigSettingsSnapshotValue,
): value is { configured: boolean } {
return Boolean(value && typeof value === 'object' && 'configured' in value);
}
function setStatus(message: string, tone: 'info' | 'error' | 'success' = 'info'): void {
dom.statusBanner.textContent = message;
dom.statusBanner.className = `status-banner ${tone}`;
@@ -132,7 +133,19 @@ function createFieldMeta(field: ConfigSettingsField): HTMLElement {
}
function valueForField(field: ConfigSettingsField): ConfigSettingsSnapshotValue {
return state.draft?.values[field.configPath] ?? field.defaultValue;
if (!state.draft) {
return field.defaultValue;
}
return Object.hasOwn(state.draft.values, field.configPath)
? state.draft.values[field.configPath]
: field.defaultValue;
}
function valueForPath(path: string): ConfigSettingsSnapshotValue | undefined {
if (!state.draft || !Object.hasOwn(state.draft.values, path)) {
return undefined;
}
return state.draft.values[path];
}
function setFieldError(path: string, message: string | null): void {
@@ -150,128 +163,6 @@ function updateDraft(path: string, value: ConfigSettingsSnapshotValue): void {
syncSaveButton();
}
function renderJsonInput(
field: ConfigSettingsField,
value: ConfigSettingsSnapshotValue,
): HTMLElement {
const textarea = createElement('textarea', 'config-textarea') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = JSON.stringify(value ?? {}, null, 2);
textarea.addEventListener('input', () => {
try {
updateDraft(field.configPath, JSON.parse(textarea.value));
textarea.classList.remove('invalid');
setFieldError(field.configPath, null);
} catch {
textarea.classList.add('invalid');
setFieldError(field.configPath, 'Invalid JSON');
}
});
return textarea;
}
function renderStringListInput(
field: ConfigSettingsField,
value: ConfigSettingsSnapshotValue,
): HTMLElement {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = Array.isArray(value) ? value.join('\n') : '';
textarea.addEventListener('input', () => {
updateDraft(
field.configPath,
textarea.value
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean),
);
});
return textarea;
}
function renderControl(field: ConfigSettingsField): HTMLElement {
const value = valueForField(field);
if (field.control === 'boolean') {
const label = createElement('label', 'switch-control');
const input = createElement('input') as HTMLInputElement;
input.type = 'checkbox';
input.checked = Boolean(value);
input.addEventListener('change', () => updateDraft(field.configPath, input.checked));
const track = createElement('span', 'switch-track');
label.append(input, track);
return label;
}
if (field.control === 'number') {
const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = 'number';
input.value = typeof value === 'number' ? String(value) : '';
input.addEventListener('input', () => {
const next = parseOptionalNumberInputValue(input.value);
if (next.ok) {
input.classList.remove('invalid');
setFieldError(field.configPath, null);
updateDraft(field.configPath, next.value);
} else {
input.classList.add('invalid');
setFieldError(field.configPath, 'Invalid number');
}
});
return input;
}
if (field.control === 'select') {
const select = createElement('select', 'config-input') as HTMLSelectElement;
for (const enumValue of field.enumValues ?? []) {
const option = createElement('option') as HTMLOptionElement;
option.value = enumValue;
option.textContent = enumValue;
option.selected = enumValue === value;
select.append(option);
}
select.addEventListener('change', () => updateDraft(field.configPath, select.value));
return select;
}
if (field.control === 'string-list') {
return renderStringListInput(field, value);
}
if (field.control === 'json') {
return renderJsonInput(field, value);
}
if (field.control === 'textarea') {
const textarea = createElement('textarea', 'config-textarea compact') as HTMLTextAreaElement;
textarea.spellcheck = false;
textarea.value = typeof value === 'string' ? value : '';
textarea.addEventListener('input', () => updateDraft(field.configPath, textarea.value));
return textarea;
}
const input = createElement('input', 'config-input') as HTMLInputElement;
input.type = field.control === 'secret' ? 'password' : field.control;
if (field.control === 'secret') {
input.placeholder =
isSecretSnapshotValue(value) && value.configured ? 'Configured' : 'Not configured';
input.addEventListener('input', () => {
if (input.value.trim().length === 0) {
if (state.draft) {
setDraftValue(state.draft, field.configPath, state.draft.initialValues[field.configPath]);
}
syncSaveButton();
return;
}
updateDraft(field.configPath, input.value);
});
} else {
input.value = typeof value === 'string' ? value : '';
input.addEventListener('input', () => updateDraft(field.configPath, input.value));
}
return input;
}
function renderWarnings(snapshot: ConfigSettingsSnapshot): void {
dom.warningsPanel.replaceChildren();
if (snapshot.warnings.length === 0) {
@@ -330,7 +221,7 @@ function renderField(field: ConfigSettingsField): HTMLElement {
header.append(label, description, createFieldMeta(field));
const controlWrap = createElement('div', 'field-control');
controlWrap.append(renderControl(field));
controlWrap.append(renderControl(field, { setFieldError, updateDraft, valueForField, valueForPath }));
const resetButton = createElement('button', 'reset-button') as HTMLButtonElement;
resetButton.type = 'button';
resetButton.textContent = 'Reset';
@@ -374,7 +265,19 @@ function renderSettingsContent(snapshot: ConfigSettingsSnapshot): void {
const title = createElement('h2');
title.textContent = section;
sectionEl.append(title);
if (section === 'Note Fields') {
sectionEl.append(
renderNoteFieldModelPicker({ setFieldError, updateDraft, valueForField, valueForPath }),
);
}
let currentSubsection = '';
for (const field of sectionFields) {
if (field.subsection && field.subsection !== currentSubsection) {
currentSubsection = field.subsection;
const subsectionTitle = createElement('h3', 'settings-subsection-title');
subsectionTitle.textContent = field.subsection;
sectionEl.append(subsectionTitle);
}
sectionEl.append(renderField(field));
}
dom.settingsContent.append(sectionEl);
@@ -390,11 +293,14 @@ function render(): void {
syncSaveButton();
}
configureSettingsControls({ requestRender: render });
async function loadSnapshot(): Promise<void> {
clearStatus();
const snapshot = await window.configSettingsAPI.getSnapshot();
state.snapshot = snapshot;
state.draft = createSettingsDraft(snapshot.values);
initializeSettingsControls(snapshot.values);
state.inputErrors.clear();
render();
}
@@ -406,34 +312,36 @@ async function save(): Promise<void> {
dom.saveButton.disabled = true;
setStatus('Saving...', 'info');
let result;
try {
const result = await window.configSettingsAPI.savePatch({ operations });
if (!result.ok || !result.snapshot) {
const message =
result.error ??
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
'Save failed';
setStatus(message, 'error');
return;
}
state.snapshot = result.snapshot;
state.draft = createSettingsDraft(result.snapshot.values);
state.inputErrors.clear();
const restartSections = result.restartRequiredSections ?? [];
if (restartSections.length > 0) {
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
} else if (result.hotReloadFields.length > 0) {
setStatus('Saved. Live settings applied.', 'success');
} else {
setStatus('Saved.', 'success');
}
render();
result = await window.configSettingsAPI.savePatch({ operations });
} catch (error) {
setStatus(error instanceof Error ? error.message : 'Save failed', 'error');
} finally {
syncSaveButton();
return;
}
if (!result.ok || !result.snapshot) {
const message =
result.error ??
result.warnings?.map((warning) => `${warning.path}: ${warning.message}`).join('\n') ??
'Save failed';
setStatus(message, 'error');
syncSaveButton();
return;
}
state.snapshot = result.snapshot;
state.draft = createSettingsDraft(result.snapshot.values);
state.inputErrors.clear();
const restartSections = result.restartRequiredSections ?? [];
if (restartSections.length > 0) {
setStatus(`Saved. Restart required: ${restartSections.join(', ')}`, 'info');
} else if (result.hotReloadFields.length > 0) {
setStatus('Saved. Live settings applied.', 'success');
} else {
setStatus('Saved.', 'success');
}
render();
}
dom.searchInput.addEventListener('input', () => {
@@ -444,7 +352,9 @@ dom.saveButton.addEventListener('click', () => {
void save();
});
dom.openFileButton.addEventListener('click', () => {
void window.configSettingsAPI.openSettingsFile();
void window.configSettingsAPI.openSettingsFile().catch((error) => {
setStatus(error instanceof Error ? error.message : 'Failed to open settings file', 'error');
});
});
void loadSnapshot().catch((error) => {
+189 -5
View File
@@ -273,6 +273,13 @@ h1 {
padding: 7px 10px;
}
.mono-input {
font-family:
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
monospace;
font-size: 12px;
}
.config-textarea {
width: min(420px, 100%);
min-height: 138px;
@@ -324,7 +331,8 @@ select.config-input option {
.primary-button,
.secondary-button,
.reset-button {
.reset-button,
.key-learn-button {
height: 36px;
border-radius: 8px;
border: 1px solid var(--line);
@@ -340,7 +348,8 @@ select.config-input option {
.primary-button:active,
.secondary-button:active,
.reset-button:active {
.reset-button:active,
.key-learn-button:active {
transform: translateY(1px);
}
@@ -365,19 +374,37 @@ select.config-input option {
}
.secondary-button,
.reset-button {
.reset-button,
.key-learn-button {
padding: 0 13px;
background: rgba(54, 58, 79, 0.5);
color: var(--text);
}
.secondary-button:hover,
.reset-button:hover {
.reset-button:hover,
.key-learn-button:hover {
border-color: rgba(138, 173, 244, 0.45);
background: rgba(73, 77, 100, 0.6);
color: var(--ctp-lavender);
}
.key-learn-button {
min-width: 146px;
max-width: 100%;
overflow-wrap: anywhere;
font-family:
'JetBrains Mono', 'SF Mono', 'M PLUS 1', 'Avenir Next', ui-monospace, SFMono-Regular, Menlo,
monospace;
font-size: 12px;
}
.key-learn-button.learning {
border-color: rgba(238, 212, 159, 0.58);
background: rgba(238, 212, 159, 0.1);
color: var(--ctp-yellow);
}
.reset-button {
height: 32px;
padding: 0 10px;
@@ -479,6 +506,18 @@ code {
text-transform: uppercase;
}
.settings-subsection-title {
margin: 0;
padding: 11px 16px 8px;
border-top: 1px solid var(--line-soft);
background: rgba(24, 25, 38, 0.32);
color: var(--ctp-sky);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.field-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 430px);
@@ -492,6 +531,14 @@ code {
border-top: none;
}
.settings-subsection-title + .field-row {
border-top: none;
}
.helper-row {
background: rgba(138, 173, 244, 0.04);
}
.field-copy h3 {
margin: 0 0 5px;
font-size: 14px;
@@ -555,6 +602,108 @@ code {
min-width: 0;
}
.stacked-control,
.deck-field-editor,
.keybinding-editor {
display: flex;
width: min(520px, 100%);
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.deck-field-row {
display: grid;
grid-template-columns: minmax(140px, 0.75fr) minmax(220px, 1.25fr) auto;
gap: 8px;
align-items: start;
}
.deck-field-fields {
display: flex;
min-width: 0;
flex-direction: column;
gap: 6px;
}
.deck-field-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.field-checkbox-list {
display: flex;
max-height: 148px;
min-height: 44px;
flex-direction: column;
gap: 2px;
overflow: auto;
padding: 6px;
border: 1px solid var(--line);
border-radius: 7px;
background: rgba(24, 25, 38, 0.58);
}
.field-checkbox-row {
display: flex;
min-height: 28px;
align-items: center;
gap: 8px;
padding: 4px 6px;
border-radius: 5px;
color: var(--ctp-subtext0);
font-size: 12px;
line-height: 1.25;
}
.field-checkbox-row:hover {
background: rgba(138, 173, 244, 0.1);
}
.field-checkbox-row input {
width: 14px;
height: 14px;
flex: 0 0 auto;
accent-color: var(--ctp-lavender);
}
.field-checkbox-row span {
min-width: 0;
overflow-wrap: anywhere;
}
.keybinding-row {
display: grid;
grid-template-columns: minmax(146px, 0.78fr) minmax(220px, 1.22fr);
gap: 8px;
align-items: start;
}
.multi-select {
min-height: 92px;
padding-right: 10px;
background-image: none !important;
}
.compact-button {
width: fit-content;
}
.icon-button {
white-space: nowrap;
}
.control-hint {
color: var(--ctp-overlay2);
font-size: 11.5px;
line-height: 1.45;
}
.control-hint.error {
color: var(--ctp-red);
}
.switch-control {
position: relative;
display: inline-flex;
@@ -611,6 +760,39 @@ code {
box-shadow: 0 0 0 3px rgba(138, 173, 244, 0.22);
}
.color-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.color-list-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-list-label {
min-width: 52px;
color: var(--ctp-subtext0);
font-size: 12.5px;
font-weight: 600;
}
.color-list-row input[type='color'] {
width: 52px;
min-height: 32px;
padding: 2px;
border: 1px solid var(--line);
border-radius: 6px;
background: rgba(24, 25, 38, 0.85);
cursor: pointer;
}
.color-list-row input[type='color']:hover {
border-color: rgba(138, 173, 244, 0.32);
}
.empty-state {
padding: 40px;
border: 1px dashed var(--line);
@@ -638,7 +820,9 @@ code {
.settings-toolbar,
.field-row,
.field-control {
.field-control,
.deck-field-row,
.keybinding-row {
display: flex;
flex-direction: column;
align-items: stretch;