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 a54f03f0cd
commit 309ce6ef8f
44 changed files with 2152 additions and 321 deletions
+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;
}