mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Unified target-note controls: key filter + fret window across all modes
- New shared PracticeFilter settings (keyTonic/keyMode/fretWindow), persisted via AppSettings.practice, defaults off (behavior unchanged). - music/targetPool.ts: single source for the effective target pool — base playable range ∩ fret window ∩ key/mode pitch classes (9 modes incl. pentatonic, same scale machinery as Phrase Mode). - useGameLogic now builds validNotes via computeTargetPool. - TargetNoteControls component (key on/off + tonic/mode, fret window on/off + min/max) rendered in Controls for all game modes; key is independent of the displayed key signature. - Phrase Mode binds its key/fret-window setup to the same shared filter (read/write mirror) and drops the duplicate controls in its setup panel. - Tests: targetPool.test.ts (7 cases); suite 156/156 green, build ok.
This commit is contained in:
119
src/components/TargetNoteControls.tsx
Normal file
119
src/components/TargetNoteControls.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Unified target-note controls, shared by all game modes (v2 controls).
|
||||
*
|
||||
* - Key filter: restrict target notes to a tonic+mode (independent of the
|
||||
* displayed key signature on the staff).
|
||||
* - Fret window: restrict target notes to a fret range (fretted instruments).
|
||||
*
|
||||
* In Phrase Mode the same controls bind to the shared practice filter
|
||||
* (mirrored into phrase settings), so state stays consistent across modes.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { Key, Guitar } from 'lucide-react';
|
||||
import { useSettings } from '../context/useSettings';
|
||||
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
|
||||
import { MODE_LABELS, TONICS, isMode, type ModeId } from '../music/scales';
|
||||
import { isFrettedInstrument } from '../music/playableRange';
|
||||
import { TUNINGS } from '../music/Tunings';
|
||||
|
||||
export const TargetNoteControls: React.FC = () => {
|
||||
const { settings, updateSettings } = useSettings();
|
||||
const pf = getPracticeFilter(settings);
|
||||
|
||||
const setFilter = (updates: Partial<PracticeFilter>) => {
|
||||
updateSettings(s => ({ ...s, practice: { ...getPracticeFilter(s), ...updates } }));
|
||||
};
|
||||
|
||||
const fretted = useMemo(
|
||||
() => isFrettedInstrument(settings.instrument) && !!TUNINGS[settings.tuningId],
|
||||
[settings.instrument, settings.tuningId],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="practice-filter-grid">
|
||||
{/* Key filter */}
|
||||
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label className="control-label">
|
||||
<Key size={18} />
|
||||
<span>Target key (limits notes)</span>
|
||||
</label>
|
||||
<button
|
||||
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
|
||||
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
|
||||
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
|
||||
>
|
||||
<div className="switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{pf.keyEnabled && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
|
||||
<select
|
||||
className="control-select"
|
||||
value={pf.keyTonic}
|
||||
onChange={e => setFilter({ keyTonic: e.target.value })}
|
||||
aria-label="Target key tonic"
|
||||
>
|
||||
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="control-select"
|
||||
value={pf.keyMode}
|
||||
onChange={e => setFilter({ keyMode: isMode(e.target.value) ? e.target.value : 'major' })}
|
||||
aria-label="Target key mode"
|
||||
>
|
||||
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
|
||||
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fret window */}
|
||||
{fretted && (
|
||||
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label className="control-label">
|
||||
<Guitar size={18} />
|
||||
<span>Fret window (limits notes)</span>
|
||||
</label>
|
||||
<button
|
||||
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}
|
||||
onClick={() => setFilter({ fretWindowEnabled: !pf.fretWindowEnabled })}
|
||||
title={pf.fretWindowEnabled ? 'Fret window: on' : 'Fret window: off'}
|
||||
>
|
||||
<div className="switch-thumb" />
|
||||
</button>
|
||||
</div>
|
||||
{pf.fretWindowEnabled && (
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span>
|
||||
<input
|
||||
type="number" min={0} max={24}
|
||||
className="control-input"
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
value={pf.fretMin}
|
||||
onChange={e => setFilter({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
|
||||
<input
|
||||
type="number" min={0} max={24}
|
||||
className="control-input"
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
value={pf.fretMax}
|
||||
onChange={e => setFilter({ fretMax: Math.max(pf.fretMin, Math.min(24, parseInt(e.target.value) || 0)) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user