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
+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) => {