From cd2938fe15f988161fc9233a6ee422d49e7d5989 Mon Sep 17 00:00:00 2001 From: Xiaohan-Tian <157918347+Xiaohan-Tian@users.noreply.github.com> Date: Fri, 29 May 2026 12:51:58 -0700 Subject: [PATCH] feat: implemented user-friendly chord guide editor; added real time chord guide info to the status bar --- src/App.tsx | 9 +- src/components/StatusBar.test.tsx | 83 +++ src/components/StatusBar.tsx | 23 +- src/components/piano-roll/PianoGrid.tsx | 35 +- .../PianoGridCandidateStatus.test.tsx | 96 ++++ src/components/piano-roll/PianoRoll.tsx | 2 + src/components/settings/Settings.css | 22 + .../sections/ChordGuideSettings.test.tsx | 183 ++++++- .../settings/sections/ChordGuideSettings.tsx | 499 ++++++++++++++---- src/core/ChordGuideTypes.ts | 13 +- src/core/config/ConfigManager.ts | 5 +- src/core/state/KGPianoRollState.ts | 25 + src/util/chordGuideConfigUtil.test.ts | 70 +++ src/util/chordGuideConfigUtil.ts | 115 ++++ src/util/chordGuideDataUtil.test.ts | 5 +- src/util/chordGuideDataUtil.ts | 31 +- 16 files changed, 1056 insertions(+), 160 deletions(-) create mode 100644 src/components/StatusBar.test.tsx create mode 100644 src/components/piano-roll/PianoGridCandidateStatus.test.tsx create mode 100644 src/util/chordGuideConfigUtil.test.ts create mode 100644 src/util/chordGuideConfigUtil.ts diff --git a/src/App.tsx b/src/App.tsx index e503545..ed466b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,9 +19,11 @@ import type { RenderingEvent } from './core/audio-interface/KGOfflineRenderer'; import { KGCore } from './core/KGCore'; import { ConfigManager } from './core/config/ConfigManager'; import { validateFunctionalChordsJSON } from './util/scaleUtil'; +import { buildChordGuideDataFromDefaultsAndConfig } from './util/chordGuideConfigUtil'; import { showAlert } from './util/dialogUtil'; import { KGProjectStorage } from './core/io/KGProjectStorage'; import { RESERVED_PROJECT_NAME } from './util/projectNameUtil'; +import type { ChordGuideCustomConfig } from './core/ChordGuideTypes'; function App() { // Enable global keyboard handler for copy/paste and undo/redo @@ -60,7 +62,8 @@ function App() { loadProject(null); // Initialize ConfigManager first to load config.json and user settings - await ConfigManager.instance().initialize(); + const configManager = ConfigManager.instance(); + await configManager.initialize(); // Check for kgone-server.json (managed deployment override) try { @@ -99,11 +102,11 @@ function App() { // Store original functional chords data KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA = functionalChordsData; console.log(`Loaded original functional chords for ${Object.keys(functionalChordsData).length} modes`); - KGCore.CHORD_GUIDE_DATA = chordGuideData; + const customChordGuideItems = configManager.get('chord_guide.custom_items') as ChordGuideCustomConfig | null | undefined; + KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(chordGuideData, customChordGuideItems); console.log(`Loaded chord guide data for ${Object.keys(chordGuideData).length} modes`); // Check if custom chord definition exists and is valid - const configManager = ConfigManager.instance(); const customDefinition = configManager.get('chord_guide.chord_definition') as string; if (customDefinition && customDefinition.trim()) { diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx new file mode 100644 index 0000000..e153e03 --- /dev/null +++ b/src/components/StatusBar.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import StatusBar from './StatusBar'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import { KGCore } from '../core/KGCore'; +import type { ChordGuideData } from '../core/ChordGuideTypes'; +import { resolveChordGuideItems } from '../util/chordGuideDataUtil'; +import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes'; +import chordGuideDataJson from '../../public/resources/modes/chord_guide.json'; + +const chordGuideData = chordGuideDataJson as ChordGuideData; + +const storeState = { + currentStatus: 'Ready', +}; + +type StoreState = typeof storeState; +type StoreSelector = (state: StoreState) => unknown; + +vi.mock('../stores/projectStore', () => ({ + useProjectStore: Object.assign( + (selector?: StoreSelector) => (selector ? selector(storeState) : storeState), + { getState: () => storeState } + ), +})); + +describe('StatusBar', () => { + beforeEach(() => { + vi.stubGlobal('__APP_VERSION__', 'test-version'); + storeState.currentStatus = 'Ready'; + KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData); + KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null); + }); + + it('shows the normal status when no chord-guide candidate is hovered', () => { + render(); + + expect(screen.getByText('Ready')).toBeTruthy(); + }); + + it('shows hovered chord-guide candidate text and restores the prior status when cleared', () => { + render(); + + const hoveredCandidate: ResolvedChordGuideItem = { + name: 'Dm7', + roman: 'ii7', + notes: ['D', 'F', 'A', 'C'], + source: 'Diatonic', + note: 'Core ii-V-I predominant sonority.', + resolvedNotes: ['D', 'F', 'A', 'C'], + pitchClasses: [2, 5, 9, 12], + }; + + act(() => { + KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(hoveredCandidate); + }); + + expect( + screen.getByText('Chord Guide Candidate: Dm7 — D F A C — Core ii-V-I predominant sonority.') + ).toBeTruthy(); + + act(() => { + KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null); + }); + + expect(screen.getByText('Ready')).toBeTruthy(); + }); + + it('uses resolved notes from the current key signature in the status text', () => { + const transposedCandidate = resolveChordGuideItems('D major', 'ionian', 'T')[0]; + + render(); + + act(() => { + KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(transposedCandidate); + }); + + expect( + screen.getByText('Chord Guide Candidate: C — D F# A — Most stable tonic triad.') + ).toBeTruthy(); + }); +}); diff --git a/src/components/StatusBar.tsx b/src/components/StatusBar.tsx index 17f23a5..9018eb4 100644 --- a/src/components/StatusBar.tsx +++ b/src/components/StatusBar.tsx @@ -1,14 +1,28 @@ -import React from 'react'; +import React, { useSyncExternalStore } from 'react'; import './StatusBar.css'; import { useProjectStore } from '../stores/projectStore'; +import { KGPianoRollState } from '../core/state/KGPianoRollState'; +import type { ResolvedChordGuideItem } from '../core/ChordGuideTypes'; + +function formatChordGuideCandidateStatus(candidate: ResolvedChordGuideItem): string { + return `Chord Guide Candidate: ${candidate.name} — ${candidate.resolvedNotes.join(' ')} — ${candidate.note}`; +} const StatusBar: React.FC = () => { const { currentStatus } = useProjectStore(); + const hoveredChordGuideCandidate = useSyncExternalStore( + (listener) => KGPianoRollState.instance().subscribe(listener), + () => KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate(), + () => null, + ); + const statusText = hoveredChordGuideCandidate + ? formatChordGuideCandidateStatus(hoveredChordGuideCandidate) + : currentStatus; return (
-
- {currentStatus} +
+ {statusText}
K.G.Studio (v{__APP_VERSION__}) @@ -17,4 +31,5 @@ const StatusBar: React.FC = () => { ); }; -export default StatusBar; \ No newline at end of file +export default StatusBar; +export { formatChordGuideCandidateStatus }; diff --git a/src/components/piano-roll/PianoGrid.tsx b/src/components/piano-roll/PianoGrid.tsx index bcb137f..978a623 100644 --- a/src/components/piano-roll/PianoGrid.tsx +++ b/src/components/piano-roll/PianoGrid.tsx @@ -11,7 +11,7 @@ import AudioWaveformCanvas from './AudioWaveformCanvas'; import type { KGAudioRegion } from '../../core/region/KGAudioRegion'; import type { SpectrogramHeightResolution } from '../../util/spectrogramUtil'; import { getNextChordCandidateIndex } from './chordGuideUtil'; -import { getMatchingChordGuideChordsForPitch } from '../../util/chordGuideDataUtil'; +import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil'; interface PianoGridProps { gridRef: MutableRefObject; @@ -160,24 +160,28 @@ const PianoGrid: React.FC = ({ }; // Get all matching chords for the current hover position - const matchingChords = useMemo(() => { + const matchingCandidates = useMemo(() => { if (!cursorPosition) return []; // If chord guide is disabled, return empty array if (chordGuide === 'N') return []; - // Use the utility function to get matching chords + // Use the utility function to get matching chords in the same order used by candidate cycling. const functionType = chordGuide as 'T' | 'S' | 'D'; - return getMatchingChordGuideChordsForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); + return getMatchingChordGuideCandidatesForPitch(cursorPosition.pitch, chordGuideKeySignature, chordGuideMode, functionType); }, [cursorPosition, chordGuide, chordGuideKeySignature, chordGuideMode]); + const matchingChords = useMemo(() => ( + matchingCandidates.map((candidate) => candidate.displayPitchClasses) + ), [matchingCandidates]); + // Calculate chord highlights based on selected chord index const chordHighlights = useMemo(() => { - if (matchingChords.length === 0) return []; + if (matchingCandidates.length === 0) return []; // Use the selected chord index (wrap around if needed) - const chordIndex = selectedChordIndex % matchingChords.length; - const matchedChordPitches = matchingChords[chordIndex]; + const chordIndex = selectedChordIndex % matchingCandidates.length; + const matchedChordPitches = matchingCandidates[chordIndex].displayPitchClasses; // Convert pitch classes to actual pitches in the same octave as cursor const highlights: Array<{ pitch: number; beat: number }> = []; @@ -195,15 +199,22 @@ const PianoGrid: React.FC = ({ } return highlights; - }, [cursorPosition, matchingChords, selectedChordIndex]); + }, [cursorPosition, matchingCandidates, selectedChordIndex]); const cursorPitch = cursorPosition?.pitch ?? null; + const selectedHoverCandidate = useMemo(() => { + if (matchingCandidates.length === 0) { + return null; + } + return matchingCandidates[selectedChordIndex % matchingCandidates.length].item; + }, [matchingCandidates, selectedChordIndex]); useEffect(() => { const pianoRollState = KGPianoRollState.instance(); pianoRollState.setCurrentMatchingChords(matchingChords); pianoRollState.setCurrentChordCursorPitch(cursorPitch); - }, [matchingChords, cursorPitch]); + pianoRollState.setCurrentHoveredChordGuideCandidate(selectedHoverCandidate); + }, [matchingChords, cursorPitch, selectedHoverCandidate]); useEffect(() => { KGPianoRollState.instance().setCurrentSelectedChordIndex(selectedChordIndex); @@ -219,8 +230,8 @@ const PianoGrid: React.FC = ({ // Expose switchChord function via window for hotkey handler useEffect(() => { const switchChord = (direction: 1 | -1 = 1) => { - if (matchingChords.length > 1) { - setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingChords.length, direction)); + if (matchingCandidates.length > 1) { + setSelectedChordIndex(prev => getNextChordCandidateIndex(prev, matchingCandidates.length, direction)); } }; @@ -232,7 +243,7 @@ const PianoGrid: React.FC = ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (window as any).__pianoGridSwitchChord; }; - }, [matchingChords.length]); + }, [matchingCandidates.length]); return (
diff --git a/src/components/piano-roll/PianoGridCandidateStatus.test.tsx b/src/components/piano-roll/PianoGridCandidateStatus.test.tsx new file mode 100644 index 0000000..ea9747b --- /dev/null +++ b/src/components/piano-roll/PianoGridCandidateStatus.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { KGCore } from '../../core/KGCore'; +import type { ChordGuideData } from '../../core/ChordGuideTypes'; +import { KGPianoRollState } from '../../core/state/KGPianoRollState'; +import { getMatchingChordGuideCandidatesForPitch } from '../../util/chordGuideDataUtil'; +import chordGuideDataJson from '../../../public/resources/modes/chord_guide.json'; + +const chordGuideData = chordGuideDataJson as ChordGuideData; + +vi.mock('../common', () => ({ + Playhead: () => null, +})); + +import PianoGrid from './PianoGrid'; + +describe('PianoGrid chord-guide hover candidate state', () => { + beforeEach(() => { + document.documentElement.style.setProperty('--region-grid-beat-width', '40'); + document.documentElement.style.setProperty('--region-piano-key-height', '20'); + KGCore.CHORD_GUIDE_DATA = structuredClone(chordGuideData); + KGCore.FUNCTIONAL_CHORDS_DATA = { + ionian: { steps: [2, 2, 1, 2, 2, 2, 1] }, + aeolian: { steps: [2, 1, 2, 2, 1, 2, 2] }, + } as unknown as typeof KGCore.FUNCTIONAL_CHORDS_DATA; + KGPianoRollState.instance().setCurrentHoveredChordGuideCandidate(null); + KGPianoRollState.instance().setCurrentMatchingChords([]); + KGPianoRollState.instance().setCurrentSelectedChordIndex(0); + KGPianoRollState.instance().setCurrentChordCursorPitch(null); + }); + + it('tracks the hovered candidate, updates on candidate cycling, and clears on mouse leave', async () => { + const gridRef = { current: null as HTMLDivElement | null }; + const { container } = render( + {}} + onClick={() => {}} + onMouseDown={() => {}} + isBoxSelecting={false} + selectionBox={{ startX: 0, startY: 0, endX: 0, endY: 0 }} + selectedMode="ionian" + keySignature="C major" + chordGuide="T" + chordGuideKeySignature="C major" + chordGuideMode="ionian" + > + {null} + + ); + + const pianoGrid = container.querySelector('.piano-grid') as HTMLDivElement; + pianoGrid.getBoundingClientRect = () => ({ + left: 0, + top: 0, + width: 800, + height: 1200, + right: 800, + bottom: 1200, + x: 0, + y: 0, + toJSON: () => ({}), + }); + + fireEvent.mouseMove(pianoGrid, { clientX: 10, clientY: 941 }); + + const expectedCandidates = getMatchingChordGuideCandidatesForPitch(60, 'C major', 'ionian', 'T'); + expect(expectedCandidates.length).toBeGreaterThan(1); + + await waitFor(() => { + expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({ + name: expectedCandidates[0].item.name, + resolvedNotes: expectedCandidates[0].item.resolvedNotes, + note: expectedCandidates[0].item.note, + }); + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__pianoGridSwitchChord(1); + + await waitFor(() => { + expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toMatchObject({ + name: expectedCandidates[1].item.name, + resolvedNotes: expectedCandidates[1].item.resolvedNotes, + note: expectedCandidates[1].item.note, + }); + }); + + fireEvent.mouseLeave(pianoGrid); + + await waitFor(() => { + expect(KGPianoRollState.instance().getCurrentHoveredChordGuideCandidate()).toBeNull(); + }); + }); +}); diff --git a/src/components/piano-roll/PianoRoll.tsx b/src/components/piano-roll/PianoRoll.tsx index 0ed991f..a8cf32e 100644 --- a/src/components/piano-roll/PianoRoll.tsx +++ b/src/components/piano-roll/PianoRoll.tsx @@ -741,6 +741,7 @@ const PianoRoll: React.FC = ({ // Disabled - clear chord data pianoRollState.setCurrentSuitableChords([]); pianoRollState.setCurrentSuitableChordsPitchClasses({}); + pianoRollState.setCurrentHoveredChordGuideCandidate(null); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Chord guide disabled - cleared suitable chords`); @@ -756,6 +757,7 @@ const PianoRoll: React.FC = ({ // Update piano roll state pianoRollState.setCurrentSuitableChords(suitableChords); pianoRollState.setCurrentSuitableChordsPitchClasses(chordsPitchClasses); + pianoRollState.setCurrentHoveredChordGuideCandidate(null); if (DEBUG_MODE.PIANO_ROLL) { console.log(`Chord guide updated: ${chordGuide} (${functionType})`); diff --git a/src/components/settings/Settings.css b/src/components/settings/Settings.css index 03c66ce..797e730 100644 --- a/src/components/settings/Settings.css +++ b/src/components/settings/Settings.css @@ -319,6 +319,28 @@ margin-bottom: 8px; } +.settings-chord-guide-reset-row { + justify-content: flex-end; +} + +.settings-chord-guide-toolbar { + margin-top: 10px; + margin-bottom: 12px; +} + +.settings-chord-guide-toolbar .event-list-toolbar-group:first-child { + gap: 8px; +} + +.settings-chord-guide-toolbar .settings-chord-guide-add-button { + border: 1px solid #444; + border-radius: 3px; +} + +.settings-chord-guide-toolbar .settings-chord-guide-add-button:hover { + border: 1px solid #5a5a5a; +} + button.settings-help { color: #5a9fd4; text-decoration: underline; diff --git a/src/components/settings/sections/ChordGuideSettings.test.tsx b/src/components/settings/sections/ChordGuideSettings.test.tsx index 154b455..d53b92b 100644 --- a/src/components/settings/sections/ChordGuideSettings.test.tsx +++ b/src/components/settings/sections/ChordGuideSettings.test.tsx @@ -1,10 +1,15 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import ChordGuideSettings from './ChordGuideSettings'; +import chordGuideDataJson from '../../../../public/resources/modes/chord_guide.json'; +import type { ChordGuideData } from '../../../core/ChordGuideTypes'; +const chordGuideData = chordGuideDataJson as ChordGuideData; + +const showAlertMock = vi.fn(); const configState = new Map([ - ['chord_guide.chord_definition', ''], + ['chord_guide.custom_items', null], ]); const configManagerMock = { @@ -22,38 +27,178 @@ vi.mock('../../../core/config/ConfigManager', () => ({ }, })); -vi.mock('../../../util/scaleUtil', async () => { - const actual = await vi.importActual('../../../util/scaleUtil'); - return { - ...actual, - validateFunctionalChordsJSON: vi.fn(() => ({ valid: true, errors: [] })), - }; -}); - vi.mock('../../../util/dialogUtil', () => ({ - showAlert: vi.fn(), + showAlert: (...args: unknown[]) => showAlertMock(...args), })); vi.mock('../../../core/KGCore', () => ({ KGCore: { - ORIGINAL_FUNCTIONAL_CHORDS_DATA: {}, - FUNCTIONAL_CHORDS_DATA: {}, + CHORD_GUIDE_DATA: { + ionian: { T: [], S: [], D: [] }, + aeolian: { T: [], S: [], D: [] }, + }, }, })); describe('ChordGuideSettings', () => { + const getGroup = (heading: 'Major Candidate Chords' | 'Minor Candidate Chords') => ( + screen.getByText(heading).closest('.settings-group') as HTMLElement + ); + beforeEach(() => { - configState.set('chord_guide.chord_definition', ''); + configState.set('chord_guide.custom_items', null); configManagerMock.get.mockClear(); configManagerMock.set.mockClear(); + showAlertMock.mockClear(); + + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes('resources/modes/chord_guide.json')) { + return { + ok: true, + status: 200, + json: async () => structuredClone(chordGuideData), + }; + } + throw new Error(`Unexpected fetch: ${url}`); + })); }); - it('renders the legacy notice for chord guide definitions', async () => { + it('renders major and minor groups with base-key guidance', async () => { render(); - expect(await screen.findByText('Legacy Chord Definition')).toBeTruthy(); - expect( - screen.getByText(/no longer affects chord-guide suggestions in the piano roll/i) - ).toBeTruthy(); + expect(await screen.findByText('Major Candidate Chords')).toBeTruthy(); + expect(screen.getByText('Minor Candidate Chords')).toBeTruthy(); + expect(screen.getByText(/relative to C major/i)).toBeTruthy(); + expect(screen.getByText(/relative to A minor/i)).toBeTruthy(); + }); + + it('switches T/S/D tabs as a mutex control', async () => { + render(); + + await screen.findByText('Major Candidate Chords'); + const majorGroup = getGroup('Major Candidate Chords'); + + expect(within(majorGroup).getByTitle('C')).toBeTruthy(); + fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' })); + + expect(within(majorGroup).getByTitle('Fmaj7')).toBeTruthy(); + expect(within(majorGroup).queryByTitle('Cmaj7')).toBeNull(); + }); + + it('adds and deletes rows in the active table', async () => { + render(); + + await screen.findByText('Major Candidate Chords'); + const addButtons = screen.getAllByTitle(/Add .* chord/); + fireEvent.click(addButtons[0]); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith( + 'chord_guide.custom_items', + expect.objectContaining({ + major: expect.objectContaining({ + T: expect.arrayContaining([expect.objectContaining({ name: 'C' })]), + }), + }) + ); + }); + + const rows = screen.getAllByRole('row'); + fireEvent.click(rows[1]); + const deleteButtons = screen.getAllByTitle('Delete selected rows'); + fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledTimes(2); + }); + }); + + it('updates notes and source after a valid chord edit', async () => { + render(); + + await screen.findByText('Major Candidate Chords'); + const majorGroup = getGroup('Major Candidate Chords'); + fireEvent.click(within(majorGroup).getByRole('button', { name: 'S' })); + + const chordCell = within(majorGroup).getByTitle('F'); + fireEvent.doubleClick(chordCell); + const input = screen.getByDisplayValue('F'); + fireEvent.change(input, { target: { value: 'D7' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as { + major: { S: Array<{ name: string; notes: string[]; source: string }> }; + }; + expect(saved.major.S[0]).toMatchObject({ + name: 'D7', + notes: ['D', 'F#', 'A', 'C'], + source: 'Non-Diatonic', + }); + }); + }); + + it('rejects invalid chord edits', async () => { + render(); + + await screen.findByText('Major Candidate Chords'); + const chordCell = screen.getAllByTitle('C')[0]; + fireEvent.doubleClick(chordCell); + const input = screen.getByDisplayValue('C'); + fireEvent.change(input, { target: { value: 'invalid' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(showAlertMock).toHaveBeenCalledWith(expect.stringContaining('valid chord symbol')); + }); + }); + + it('enforces the 128-character note limit', async () => { + render(); + + await screen.findByText('Major Candidate Chords'); + const noteCell = screen.getAllByRole('cell').find((cell) => cell.textContent === chordGuideData.ionian.T[0].note) as HTMLElement; + fireEvent.doubleClick(noteCell); + const input = screen.getByDisplayValue(chordGuideData.ionian.T[0].note); + fireEvent.change(input, { target: { value: 'x'.repeat(200) } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + const saved = configManagerMock.set.mock.calls.at(-1)?.[1] as { major: { T: Array<{ note: string }> } }; + expect(saved.major.T[0].note).toHaveLength(128); + }); + }); + + it('resets back to bundled defaults', async () => { + const customItems = structuredClone(chordGuideData); + customItems.ionian.T = [{ + name: 'G', + notes: ['G', 'B', 'D'], + source: 'Diatonic', + note: 'custom', + }]; + configState.set('chord_guide.custom_items', { + major: { + T: customItems.ionian.T, + S: customItems.ionian.S, + D: customItems.ionian.D, + }, + minor: { + T: customItems.aeolian.T, + S: customItems.aeolian.S, + D: customItems.aeolian.D, + }, + }); + + render(); + + expect((await screen.findAllByTitle('G')).length).toBeGreaterThan(0); + fireEvent.click(screen.getByText('Reset to Default')); + + await waitFor(() => { + expect(configManagerMock.set).toHaveBeenCalledWith('chord_guide.custom_items', null); + expect(screen.getAllByTitle('C')[0]).toBeTruthy(); + }); }); }); diff --git a/src/components/settings/sections/ChordGuideSettings.tsx b/src/components/settings/sections/ChordGuideSettings.tsx index 97761ff..9f7fe38 100644 --- a/src/components/settings/sections/ChordGuideSettings.tsx +++ b/src/components/settings/sections/ChordGuideSettings.tsx @@ -1,108 +1,415 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React from 'react'; +import { FaPlus, FaTrash } from 'react-icons/fa'; +import '../../EventListPanel.css'; import { ConfigManager } from '../../../core/config/ConfigManager'; -import { validateFunctionalChordsJSON } from '../../../util/scaleUtil'; import { KGCore } from '../../../core/KGCore'; +import type { + ChordGuideCustomConfig, + ChordGuideData, + ChordGuideGroupKey, + ChordGuideItem, + ChordGuideModeDefinition, +} from '../../../core/ChordGuideTypes'; +import { + buildChordGuideCustomConfigFromData, + buildChordGuideDataFromDefaultsAndConfig, + buildDerivedChordGuideItem, + createDefaultChordForGroup, +} from '../../../util/chordGuideConfigUtil'; +import { parseChordSymbol } from '../../../util/chordUtil'; import { showAlert } from '../../../util/dialogUtil'; +import { isModifierKeyPressed } from '../../../util/osUtil'; + +type FunctionType = 'T' | 'S' | 'D'; +type EditableColumn = 'name' | 'note'; + +interface EditingCell { + group: ChordGuideGroupKey; + rowIndex: number; + column: EditableColumn; + value: string; +} + +const FUNCTION_BUTTONS: Array<{ value: FunctionType; label: string; title: string }> = [ + { value: 'T', label: 'T', title: 'Tonic' }, + { value: 'S', label: 'S', title: 'Subdominant' }, + { value: 'D', label: 'D', title: 'Dominant' }, +]; + +const GROUP_LABELS: Record = { + major: 'Major Candidate Chords', + minor: 'Minor Candidate Chords', +}; + +const GROUP_HELP: Record = { + major: 'Enter chords relative to C major. The app will transpose them automatically for other major key signatures.', + minor: 'Enter chords relative to A minor. The app will transpose them automatically for other minor key signatures.', +}; + +const CONFIG_KEY = 'chord_guide.custom_items'; + +function cloneModeDefinition(definition: ChordGuideModeDefinition): ChordGuideModeDefinition { + return { + T: definition.T.map((item) => ({ ...item, notes: [...item.notes] })), + S: definition.S.map((item) => ({ ...item, notes: [...item.notes] })), + D: definition.D.map((item) => ({ ...item, notes: [...item.notes] })), + }; +} + +function cloneCustomConfig(config: ChordGuideCustomConfig): ChordGuideCustomConfig { + return { + major: cloneModeDefinition(config.major), + minor: cloneModeDefinition(config.minor), + }; +} + +function getModeDefinition(config: ChordGuideCustomConfig, group: ChordGuideGroupKey): ChordGuideModeDefinition { + return group === 'major' ? config.major : config.minor; +} + +function setModeDefinition( + config: ChordGuideCustomConfig, + group: ChordGuideGroupKey, + definition: ChordGuideModeDefinition, +): ChordGuideCustomConfig { + return group === 'major' + ? { ...config, major: definition } + : { ...config, minor: definition }; +} + +async function loadBundledChordGuideData(): Promise { + const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/chord_guide.json`); + if (!response.ok) { + throw new Error(`Failed to fetch chord_guide.json: ${response.status}`); + } + return response.json() as Promise; +} const ChordGuideSettings: React.FC = () => { - const [chordDefinition, setChordDefinition] = useState(''); - const [validationErrors, setValidationErrors] = useState([]); - const configManager = ConfigManager.instance(); + const [defaultsData, setDefaultsData] = React.useState(null); + const [customConfig, setCustomConfig] = React.useState(null); + const [activeFunctions, setActiveFunctions] = React.useState>({ + major: 'T', + minor: 'T', + }); + const [selectedRows, setSelectedRows] = React.useState>>({ + major: new Set(), + minor: new Set(), + }); + const [editingCell, setEditingCell] = React.useState(null); + const editInputRef = React.useRef(null); + const previousEditingCellKeyRef = React.useRef(null); - // Load configuration values on component mount - useEffect(() => { - const loadConfig = async () => { + React.useEffect(() => { + const initialize = async () => { if (!configManager.getIsInitialized()) { await configManager.initialize(); } - setChordDefinition((configManager.get('chord_guide.chord_definition') as string) || ''); + const bundledData = await loadBundledChordGuideData(); + const persisted = configManager.get(CONFIG_KEY) as ChordGuideCustomConfig | null | undefined; + setDefaultsData(bundledData); + setCustomConfig(persisted ? cloneCustomConfig(persisted) : buildChordGuideCustomConfigFromData(bundledData)); }; - loadConfig(); + void initialize().catch((error) => { + console.error('Failed to initialize chord guide settings:', error); + void showAlert('Failed to load chord guide settings.'); + }); }, [configManager]); - // Debounced save function for textarea - const debouncedSave = useCallback((value: string) => { - const timeoutId = setTimeout(async () => { - try { - await configManager.set('chord_guide.chord_definition', value); - console.log('Chord definition saved'); - } catch (error) { - console.error('Failed to save chord definition:', error); + React.useEffect(() => { + if (!editingCell) { + previousEditingCellKeyRef.current = null; + return; + } + + const editingCellKey = `${editingCell.group}-${editingCell.rowIndex}-${editingCell.column}`; + if (previousEditingCellKeyRef.current === editingCellKey) { + return; + } + previousEditingCellKeyRef.current = editingCellKey; + + editInputRef.current?.focus(); + editInputRef.current?.select(); + }, [editingCell]); + + const persistCustomConfig = React.useCallback(async (nextConfig: ChordGuideCustomConfig, persistValue: ChordGuideCustomConfig | null = nextConfig) => { + if (!defaultsData) { + return; + } + const cloned = cloneCustomConfig(nextConfig); + setCustomConfig(cloned); + KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, cloned); + await configManager.set(CONFIG_KEY, persistValue ? cloneCustomConfig(persistValue) : null); + }, [configManager, defaultsData]); + + const updateGroupRows = React.useCallback(async ( + group: ChordGuideGroupKey, + functionType: FunctionType, + updater: (rows: ChordGuideItem[]) => ChordGuideItem[], + ) => { + if (!customConfig) { + return; + } + const nextConfig = cloneCustomConfig(customConfig); + const modeDefinition = getModeDefinition(nextConfig, group); + const nextDefinition: ChordGuideModeDefinition = { + ...modeDefinition, + [functionType]: updater(modeDefinition[functionType]), + }; + const updatedConfig = setModeDefinition(nextConfig, group, nextDefinition); + await persistCustomConfig(updatedConfig); + }, [customConfig, persistCustomConfig]); + + const commitEditingCell = React.useCallback(async () => { + if (!editingCell || !customConfig) { + return; + } + + const { group, rowIndex, column } = editingCell; + const functionType = activeFunctions[group]; + const modeDefinition = getModeDefinition(customConfig, group); + const row = modeDefinition[functionType][rowIndex]; + if (!row) { + setEditingCell(null); + return; + } + + const trimmedValue = editingCell.value.trim(); + if (column === 'name') { + if (!trimmedValue || parseChordSymbol(trimmedValue) === null) { + await showAlert('Please enter a valid chord symbol. Example: Bm7b5'); + return; } - }, 1000); // 1 second debounce for longer text + const derived = buildDerivedChordGuideItem(group, { name: trimmedValue, note: row.note }); + if (!derived) { + await showAlert('Unable to derive notes for this chord. Please use a supported chord symbol.'); + return; + } + await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => ( + index === rowIndex + ? { ...derived, roman: undefined } + : candidate + ))); + } else { + await updateGroupRows(group, functionType, (rows) => rows.map((candidate, index) => ( + index === rowIndex + ? { ...candidate, note: trimmedValue.slice(0, 128) } + : candidate + ))); + } - return () => clearTimeout(timeoutId); - }, [configManager]); + setEditingCell(null); + }, [activeFunctions, customConfig, editingCell, updateGroupRows]); - // Save configuration when value changes - const handleChordDefinitionChange = (value: string) => { - setChordDefinition(value); + const handleEditInputBlur = () => { + void commitEditingCell(); + }; - // Validate the JSON - if (value.trim()) { - const validationResult = validateFunctionalChordsJSON(value); - setValidationErrors(validationResult.valid ? [] : validationResult.errors); + const handleEditInputKeyDown = (event: React.KeyboardEvent) => { + event.stopPropagation(); + if (event.key === 'Enter') { + event.preventDefault(); + void commitEditingCell(); + } + if (event.key === 'Escape') { + event.preventDefault(); + setEditingCell(null); + } + }; - // Update FUNCTIONAL_CHORDS_DATA if valid and non-empty - if (validationResult.valid) { - try { - KGCore.FUNCTIONAL_CHORDS_DATA = JSON.parse(value); - console.log('Applied custom chord definition'); - } catch (error) { - console.error('Failed to parse chord definition:', error); - KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; - } + const handleRowClick = ( + group: ChordGuideGroupKey, + rowIndex: number, + event: React.MouseEvent, + ) => { + event.stopPropagation(); + if (editingCell) { + return; + } + + const nextSelected = new Set(selectedRows[group]); + if (isModifierKeyPressed(event)) { + if (nextSelected.has(rowIndex)) { + nextSelected.delete(rowIndex); } else { - // Revert to original if invalid - KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; - console.log('Invalid chord definition, reverted to original'); + nextSelected.add(rowIndex); } } else { - setValidationErrors([]); - // Revert to original if empty - KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; - console.log('Chord definition cleared, reverted to original'); + nextSelected.clear(); + nextSelected.add(rowIndex); } - debouncedSave(value); + setSelectedRows((previous) => ({ ...previous, [group]: nextSelected })); }; - // Load default template from functional_chords.json (preserving original formatting) - const handleLoadDefaultTemplate = async () => { - try { - const response = await fetch(`${import.meta.env.BASE_URL}resources/modes/functional_chords.json`); - if (!response.ok) { - throw new Error(`Failed to fetch functional_chords.json: ${response.status}`); - } - // Get the raw text to preserve original formatting - const rawText = await response.text(); - setChordDefinition(rawText); - setValidationErrors([]); // Clear errors when loading valid template - await configManager.set('chord_guide.chord_definition', rawText); - - // Revert to original if empty - KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; - - console.log('Loaded default chord template'); - } catch (error) { - console.error('Failed to load default template:', error); - await showAlert('Failed to load default template. Please check the console for details.'); + const handleTableBackgroundMouseDown = (group: ChordGuideGroupKey, event: React.MouseEvent) => { + if (event.target !== event.currentTarget) { + return; } + setSelectedRows((previous) => ({ ...previous, [group]: new Set() })); }; - // Clear the chord definition - const handleClear = async () => { - setChordDefinition(''); - setValidationErrors([]); // Clear errors when clearing - await configManager.set('chord_guide.chord_definition', ''); + const handleAddRow = async (group: ChordGuideGroupKey) => { + const functionType = activeFunctions[group]; + const defaultChord = createDefaultChordForGroup(group); + await updateGroupRows(group, functionType, (rows) => [...rows, { ...defaultChord, roman: undefined }]); + setSelectedRows((previous) => ({ ...previous, [group]: new Set([getModeDefinition(customConfig!, group)[functionType].length]) })); + }; - // Revert to original if empty - KGCore.FUNCTIONAL_CHORDS_DATA = KGCore.ORIGINAL_FUNCTIONAL_CHORDS_DATA; - - console.log('Chord definition cleared'); + const handleDeleteRows = async (group: ChordGuideGroupKey) => { + const selection = selectedRows[group]; + if (selection.size === 0) { + return; + } + const functionType = activeFunctions[group]; + await updateGroupRows(group, functionType, (rows) => rows.filter((_, index) => !selection.has(index))); + setSelectedRows((previous) => ({ ...previous, [group]: new Set() })); + }; + + const handleResetToDefault = async () => { + if (!defaultsData) { + return; + } + const resetConfig = buildChordGuideCustomConfigFromData(defaultsData); + setEditingCell(null); + setSelectedRows({ major: new Set(), minor: new Set() }); + setCustomConfig(resetConfig); + KGCore.CHORD_GUIDE_DATA = buildChordGuideDataFromDefaultsAndConfig(defaultsData, resetConfig); + await configManager.set(CONFIG_KEY, null); + }; + + const renderGroup = (group: ChordGuideGroupKey) => { + if (!customConfig) { + return null; + } + + const functionType = activeFunctions[group]; + const rows = getModeDefinition(customConfig, group)[functionType]; + const selection = selectedRows[group]; + + return ( +
+
+

{GROUP_LABELS[group]}

+
+
{GROUP_HELP[group]}
+ +
+ {FUNCTION_BUTTONS.map((button) => ( + + ))} +
+ +
+
+ +
+
+ +
+
+ +
handleTableBackgroundMouseDown(group, event)}> + + + + + + + + + + + {rows.map((row, rowIndex) => { + const isEditingName = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'name'; + const isEditingNote = editingCell?.group === group && editingCell.rowIndex === rowIndex && editingCell.column === 'note'; + return ( + handleRowClick(group, rowIndex, event)} + > + + + + + + ); + })} + +
ChordNotesSourceNote
{ + event.stopPropagation(); + setEditingCell({ group, rowIndex, column: 'name', value: row.name }); + }} + > + {isEditingName ? ( + setEditingCell({ ...editingCell, value: event.target.value })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={handleEditInputKeyDown} + /> + ) : row.name} + {row.notes.join(' ')}{row.source} { + event.stopPropagation(); + setEditingCell({ group, rowIndex, column: 'note', value: row.note }); + }} + > + {isEditingNote ? ( + setEditingCell({ ...editingCell, value: event.target.value.slice(0, 128) })} + onBlur={handleEditInputBlur} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onKeyDown={handleEditInputKeyDown} + /> + ) : row.note} +
+
+
+ ); }; return ( @@ -112,45 +419,13 @@ const ChordGuideSettings: React.FC = () => {
-
-

Legacy Chord Definition

-
- This editor is kept for legacy mode and highlighting behavior. It no longer affects chord-guide suggestions in the piano roll. -
-
- - -
- -
-