Anki maturity-based known-word highlighting (#172)

This commit is contained in:
2026-07-27 23:58:02 -07:00
committed by GitHub
parent 08c6807cb1
commit 987b325edb
54 changed files with 3046 additions and 381 deletions
+59 -6
View File
@@ -2,6 +2,12 @@ import type { SessionHelpSection } from './session-help-sections';
export type SessionHelpSubtitleStyle = {
knownWordColor?: unknown;
knownWordMaturityColors?: {
new?: unknown;
learning?: unknown;
young?: unknown;
mature?: unknown;
};
nPlusOneColor?: unknown;
nameMatchColor?: unknown;
jlptColors?: {
@@ -13,10 +19,19 @@ export type SessionHelpSubtitleStyle = {
};
};
export type SessionHelpColorOptions = {
/** When true, known words are colored per Anki card maturity instead of one flat color. */
knownWordMaturityEnabled?: boolean;
};
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const FALLBACK_COLORS = {
knownWordColor: '#a6da95',
knownWordMaturityNewColor: '#ee99a0',
knownWordMaturityLearningColor: '#b7bdf8',
knownWordMaturityYoungColor: '#91d7e3',
knownWordMaturityMatureColor: '#a6da95',
nPlusOneColor: '#c6a0f6',
nameMatchColor: '#f5bde6',
jlptN1Color: '#ed8796',
@@ -32,15 +47,53 @@ function normalizeColor(value: unknown, fallback: string): string {
return HEX_COLOR_RE.test(next) ? next : fallback;
}
export function buildColorSection(style: SessionHelpSubtitleStyle): SessionHelpSection {
function buildKnownWordRows(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions,
): SessionHelpSection['rows'] {
if (!options.knownWordMaturityEnabled) {
const knownWordColor = normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor);
return [{ shortcut: 'Known words', action: knownWordColor, color: knownWordColor }];
}
const maturityColors = style.knownWordMaturityColors;
const tiers: Array<{ label: string; value: unknown; fallback: string }> = [
{
label: 'Known words (new)',
value: maturityColors?.new,
fallback: FALLBACK_COLORS.knownWordMaturityNewColor,
},
{
label: 'Known words (learning)',
value: maturityColors?.learning,
fallback: FALLBACK_COLORS.knownWordMaturityLearningColor,
},
{
label: 'Known words (young)',
value: maturityColors?.young,
fallback: FALLBACK_COLORS.knownWordMaturityYoungColor,
},
{
label: 'Known words (mature)',
value: maturityColors?.mature,
fallback: FALLBACK_COLORS.knownWordMaturityMatureColor,
},
];
return tiers.map((tier) => {
const color = normalizeColor(tier.value, tier.fallback);
return { shortcut: tier.label, action: color, color };
});
}
export function buildColorSection(
style: SessionHelpSubtitleStyle,
options: SessionHelpColorOptions = {},
): SessionHelpSection {
return {
title: 'Color legend',
rows: [
{
shortcut: 'Known words',
action: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
color: normalizeColor(style.knownWordColor, FALLBACK_COLORS.knownWordColor),
},
...buildKnownWordRows(style, options),
{
shortcut: 'N+1 words',
action: normalizeColor(style.nPlusOneColor, FALLBACK_COLORS.nPlusOneColor),
+4 -1
View File
@@ -403,6 +403,7 @@ export function buildSessionHelpSections(input: {
markWatchedKey?: string | null;
subtitleSidebarToggleKey?: string | null;
subtitleStyle: SessionHelpSubtitleStyle | null | undefined;
knownWordMaturityEnabled?: boolean;
}): SessionHelpSection[] {
const sessionBindings = input.sessionBindings.filter((binding) => {
if (binding.actionType !== 'session-action') return true;
@@ -420,7 +421,9 @@ export function buildSessionHelpSections(input: {
subtitleSidebarToggleKey: input.subtitleSidebarToggleKey,
}),
...buildFixedOverlaySections(),
buildColorSection(input.subtitleStyle ?? {}),
buildColorSection(input.subtitleStyle ?? {}, {
knownWordMaturityEnabled: input.knownWordMaturityEnabled,
}),
]);
}
+98
View File
@@ -9,7 +9,9 @@ import {
createSessionHelpModal,
describeSessionHelpCommand,
formatSessionHelpKeybinding,
isKnownWordMaturityLegendEnabled,
} from './session-help.js';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options.js';
test('session help describes sub-seek commands as subtitle-line navigation', () => {
assert.equal(describeSessionHelpCommand(['sub-seek', 1]), 'Jump to next subtitle');
@@ -104,6 +106,101 @@ test('session help builds rows from canonical session bindings and fixed overlay
assert.ok(rows.some((row) => row.shortcut === 'Y then D' && row.action === 'Toggle DevTools'));
});
function booleanRuntimeOption(id: RuntimeOptionId, value: boolean): RuntimeOptionState {
return {
id,
label: id,
scope: 'subtitle',
valueType: 'boolean',
value,
allowedValues: [true, false],
requiresRestart: false,
};
}
test('maturity legend requires both known-word highlighting and maturity coloring', () => {
const highlightOn = booleanRuntimeOption('subtitle.annotation.knownWords.highlightEnabled', true);
const highlightOff = booleanRuntimeOption(
'subtitle.annotation.knownWords.highlightEnabled',
false,
);
const maturityOn = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', true);
const maturityOff = booleanRuntimeOption('subtitle.annotation.knownWords.maturityEnabled', false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOn]), true);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOff, maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([highlightOn, maturityOff]), false);
assert.equal(isKnownWordMaturityLegendEnabled([maturityOn]), false);
assert.equal(isKnownWordMaturityLegendEnabled([]), false);
});
function colorLegendRows(input: Parameters<typeof buildSessionHelpSections>[0]) {
const sections = buildSessionHelpSections(input);
return sections.find((section) => section.title === 'Color legend')?.rows ?? [];
}
test('color legend shows the flat known-word color when maturity coloring is off', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#a6da95',
},
},
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[{ shortcut: 'Known words', action: '#a6da95', color: '#a6da95' }],
);
});
test('color legend swaps in maturity tiers when maturity coloring is on', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordColor: '#a6da95',
knownWordMaturityColors: {
new: '#ee99a0',
learning: '#b7bdf8',
young: '#91d7e3',
mature: '#f0c6c6',
},
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')),
[
{ shortcut: 'Known words (new)', action: '#ee99a0', color: '#ee99a0' },
{ shortcut: 'Known words (learning)', action: '#b7bdf8', color: '#b7bdf8' },
{ shortcut: 'Known words (young)', action: '#91d7e3', color: '#91d7e3' },
{ shortcut: 'Known words (mature)', action: '#f0c6c6', color: '#f0c6c6' },
],
);
assert.ok(rows.some((row) => row.shortcut === 'N+1 words'));
});
test('color legend falls back to default maturity colors when overrides are invalid', () => {
const rows = colorLegendRows({
sessionBindings: [],
subtitleStyle: {
knownWordMaturityColors: { new: 'not-a-color', learning: 42 },
},
knownWordMaturityEnabled: true,
});
assert.deepEqual(
rows.filter((row) => row.shortcut.startsWith('Known words')).map((row) => row.color),
['#ee99a0', '#b7bdf8', '#91d7e3', '#a6da95'],
);
});
function createClassList(initialTokens: string[] = []) {
const tokens = new Set(initialTokens);
return {
@@ -176,6 +273,7 @@ test('modal-layer session help does not focus hidden main overlay and still clos
getSubtitleSidebarSnapshot: async () => ({
config: { toggleKey: 'Backslash' },
}),
getRuntimeOptions: async () => [],
},
focus: () => {},
addEventListener: () => {},
+44 -10
View File
@@ -1,4 +1,5 @@
import type { ModalStateReader, RendererContext } from '../context';
import type { RuntimeOptionId, RuntimeOptionState } from '../../types/runtime-options';
import {
buildSessionHelpSections,
type SessionHelpSection,
@@ -19,6 +20,32 @@ type SessionHelpBindingInfo = {
fallbackUnavailable: boolean;
};
/**
* Tiers only render when known-word highlighting is also on, matching
* getKnownWordMaturityEnabled in anki-integration/known-word-maturity.
*/
export function isKnownWordMaturityLegendEnabled(runtimeOptions: RuntimeOptionState[]): boolean {
const isOn = (id: RuntimeOptionId): boolean =>
runtimeOptions.some((option) => option.id === id && option.value === true);
return (
isOn('subtitle.annotation.knownWords.highlightEnabled') &&
isOn('subtitle.annotation.knownWords.maturityEnabled')
);
}
/**
* Maturity coloring is a live runtime toggle, so the color legend reads it from
* runtime options instead of the resolved subtitle style. A missing or failing
* runtime-options call falls back to the flat known-word color.
*/
async function readKnownWordMaturityEnabled(): Promise<boolean> {
try {
return isKnownWordMaturityLegendEnabled(await window.electronAPI.getRuntimeOptions());
} catch {
return false;
}
}
function formatBindingHint(info: SessionHelpBindingInfo): string {
if (info.bindingKey === 'KeyK' && info.fallbackUsed) {
return info.fallbackUnavailable ? 'Y-K (fallback and conflict noted)' : 'Y-K (fallback)';
@@ -219,22 +246,29 @@ export function createSessionHelpModal(
async function render(): Promise<boolean> {
try {
const [sessionBindings, styleConfig, markWatchedKey, subtitleSidebarToggleKey] =
await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
]);
const [
sessionBindings,
styleConfig,
markWatchedKey,
subtitleSidebarToggleKey,
knownWordMaturityEnabled,
] = await Promise.all([
window.electronAPI.getSessionBindings(),
window.electronAPI.getSubtitleStyle(),
window.electronAPI.getMarkWatchedKey(),
window.electronAPI
.getSubtitleSidebarSnapshot()
.then((snapshot) => snapshot.config.toggleKey)
.catch(() => undefined),
readKnownWordMaturityEnabled(),
]);
helpSections = buildSessionHelpSections({
sessionBindings,
markWatchedKey,
subtitleSidebarToggleKey,
subtitleStyle: styleConfig ?? {},
knownWordMaturityEnabled,
});
applyFilterAndRender();
return true;