506 lines
20 KiB
Markdown
506 lines
20 KiB
Markdown
---
|
|
phase: 09-scan-cancellation-keyboard-shortcuts
|
|
plan: 04
|
|
type: execute
|
|
wave: 2
|
|
depends_on:
|
|
- 09-02
|
|
files_modified:
|
|
- frontend/src/components/config-page/shortcut-capture.ts
|
|
- frontend/src/components/config-page/config-page.ts
|
|
autonomous: true
|
|
requirements:
|
|
- KEY-02
|
|
- KEY-03
|
|
|
|
must_haves:
|
|
truths:
|
|
- "User can see all keyboard shortcuts grouped by category (Player, Navigation, App) in a Keyboard Shortcuts tab"
|
|
- "User can click a shortcut row and press a new key combo to rebind it (record-style capture)"
|
|
- "Conflicts are detected and shown — user can overwrite (old becomes unbound) or cancel"
|
|
- "Reset to defaults button resets all shortcuts"
|
|
- "Individual per-shortcut reset is available"
|
|
artifacts:
|
|
- path: "frontend/src/components/config-page/shortcut-capture.ts"
|
|
provides: "Record-style key capture web component"
|
|
exports: ["ShortcutCapture"]
|
|
- path: "frontend/src/components/config-page/config-page.ts"
|
|
provides: "Keyboard Shortcuts tab in settings"
|
|
contains: "renderShortcutsSection"
|
|
key_links:
|
|
- from: "frontend/src/components/config-page/shortcut-capture.ts"
|
|
to: "frontend/src/services/keyboard-shortcut-service.ts"
|
|
via: "Uses buildKeyString for consistent key combo normalization"
|
|
pattern: "buildKeyString"
|
|
- from: "frontend/src/components/config-page/config-page.ts"
|
|
to: "frontend/src/store/shortcuts-store.ts"
|
|
via: "ShortcutsController for reactive state, store methods for persistence"
|
|
pattern: "shortcutsStore|ShortcutsController"
|
|
---
|
|
|
|
<objective>
|
|
Create the Keyboard Shortcuts settings UI with record-style key capture, conflict detection, and category grouping.
|
|
|
|
Purpose: Frontend UX for KEY-02/03 — visual shortcut customization with conflict warnings.
|
|
Output: shortcut-capture.ts component, Keyboard Shortcuts tab added to config-page.ts.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
|
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md
|
|
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md
|
|
@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md
|
|
|
|
@frontend/src/components/config-page/config-page.ts
|
|
@frontend/src/store/shortcuts-store.ts
|
|
@frontend/src/services/keyboard-shortcut-service.ts
|
|
|
|
<interfaces>
|
|
<!-- From Plan 02: shortcuts store API -->
|
|
class ShortcutsStore {
|
|
getBindings(): Map<string, string>; // action → key combo
|
|
getKeyForAction(action: string): string;
|
|
updateBinding(action: string, key: string): Promise<void>;
|
|
resetAll(): Promise<void>;
|
|
findConflict(key: string, scope: string, excludeAction: string): { action: string; key: string } | null;
|
|
subscribe(cb: (state: ShortcutsState) => void): () => void;
|
|
getState(): ShortcutsState;
|
|
}
|
|
export const shortcutsStore: ShortcutsStore;
|
|
export class ShortcutsController implements ReactiveController { state: ShortcutsState; }
|
|
|
|
<!-- From Plan 02: buildKeyString export -->
|
|
export function buildKeyString(e: KeyboardEvent): string;
|
|
|
|
<!-- From Plan 02: default bindings with scope metadata -->
|
|
// Action scopes (derived from action prefix):
|
|
// - "player.*", "nav.*", "app.*" → global scope
|
|
// - "tracklist.*" → panel:track-list scope
|
|
|
|
// Action categories (for UI grouping):
|
|
// - Player: player.playPause, player.next, player.previous, player.volumeUp, player.volumeDown,
|
|
// player.seekForward, player.seekBack, player.shuffle, player.repeat, player.mute
|
|
// - Navigation: nav.search, nav.searchAlt, nav.queue, tracklist.play, tracklist.delete
|
|
// - App: app.selectAll
|
|
|
|
<!-- Existing config-page rendering pattern -->
|
|
// Currently renders 4 sections vertically: Theme, Favorites, Track List Columns, Library
|
|
// Each section uses <config-section> component
|
|
// Per user decision: Shortcuts lives as a "Keyboard Shortcuts" tab within the settings dialog
|
|
// Since the current layout is vertical sections (NOT tabbed), add "Keyboard Shortcuts" as
|
|
// a new <config-section> alongside the existing ones.
|
|
// If/when tabs are needed, that's a layout change beyond this phase.
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create shortcut-capture web component</name>
|
|
<files>frontend/src/components/config-page/shortcut-capture.ts</files>
|
|
<action>
|
|
Create `frontend/src/components/config-page/shortcut-capture.ts` — a record-style key capture widget inspired by VS Code's keybinding editor.
|
|
|
|
The component:
|
|
- Displays the current key binding as a styled button/badge
|
|
- When clicked, enters "recording" mode — displays "Press a key combo..." prompt
|
|
- Captures the next keydown event and normalizes it via `buildKeyString`
|
|
- On Escape during recording: cancels, returns to display mode
|
|
- On valid key: exits recording, dispatches `shortcut-change` CustomEvent with `{ action, key }` detail
|
|
- On bare modifier press (Ctrl alone, etc.): stays in recording mode (buildKeyString returns '')
|
|
|
|
```typescript
|
|
import { LitElement, html, css } from 'lit';
|
|
import { customElement, property, state } from 'lit/decorators.js';
|
|
import { buildKeyString } from '../../services/keyboard-shortcut-service';
|
|
|
|
@customElement('shortcut-capture')
|
|
export class ShortcutCapture extends LitElement {
|
|
@property() action = '';
|
|
@property() currentKey = '';
|
|
@property() defaultKey = '';
|
|
|
|
@state() private recording = false;
|
|
|
|
static styles = css`
|
|
:host {
|
|
display: inline-block;
|
|
}
|
|
button {
|
|
font-family: inherit;
|
|
font-size: var(--yj-text-sm, 13px);
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
border: 1px solid var(--yj-border, #555);
|
|
background: var(--yj-bg-input, #333);
|
|
color: var(--yj-text-primary, #eee);
|
|
cursor: pointer;
|
|
min-width: 80px;
|
|
text-align: center;
|
|
transition: border-color 0.15s, background 0.15s;
|
|
}
|
|
button:hover {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
}
|
|
button.recording {
|
|
border-color: var(--yj-accent, #ffd43b);
|
|
background: var(--yj-bg-active, #444);
|
|
animation: pulse 1.2s ease-in-out infinite;
|
|
}
|
|
button.not-set {
|
|
color: var(--yj-text-tertiary, #888);
|
|
font-style: italic;
|
|
}
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
.reset-btn {
|
|
font-size: var(--yj-text-xs, 11px);
|
|
padding: 2px 6px;
|
|
margin-left: 4px;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--yj-text-tertiary, #888);
|
|
cursor: pointer;
|
|
min-width: auto;
|
|
opacity: 0;
|
|
transition: opacity 0.15s;
|
|
}
|
|
:host(:hover) .reset-btn {
|
|
opacity: 1;
|
|
}
|
|
.reset-btn:hover {
|
|
color: var(--yj-accent, #ffd43b);
|
|
}
|
|
`;
|
|
|
|
private handleClick = () => {
|
|
this.recording = true;
|
|
// Focus self so keydown events arrive
|
|
this.shadowRoot?.querySelector('button')?.focus();
|
|
};
|
|
|
|
private handleKeydown = (e: KeyboardEvent) => {
|
|
if (!this.recording) return;
|
|
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
const keyStr = buildKeyString(e);
|
|
if (!keyStr) return; // bare modifier press — keep recording
|
|
|
|
if (keyStr === 'Escape') {
|
|
this.recording = false;
|
|
return;
|
|
}
|
|
|
|
this.recording = false;
|
|
|
|
this.dispatchEvent(new CustomEvent('shortcut-change', {
|
|
detail: { action: this.action, key: keyStr },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
};
|
|
|
|
private handleBlur = () => {
|
|
// Cancel recording if focus leaves
|
|
if (this.recording) {
|
|
this.recording = false;
|
|
}
|
|
};
|
|
|
|
private handleReset = (e: Event) => {
|
|
e.stopPropagation();
|
|
if (this.defaultKey && this.currentKey !== this.defaultKey) {
|
|
this.dispatchEvent(new CustomEvent('shortcut-change', {
|
|
detail: { action: this.action, key: this.defaultKey },
|
|
bubbles: true,
|
|
composed: true,
|
|
}));
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const showReset = this.defaultKey && this.currentKey !== this.defaultKey;
|
|
return html`
|
|
<button
|
|
class=${this.recording ? 'recording' : this.currentKey ? '' : 'not-set'}
|
|
@click=${this.handleClick}
|
|
@keydown=${this.handleKeydown}
|
|
@blur=${this.handleBlur}
|
|
>
|
|
${this.recording
|
|
? 'Press a key combo\u2026'
|
|
: this.currentKey || 'Not set'}
|
|
</button>
|
|
${showReset ? html`
|
|
<button class="reset-btn" @click=${this.handleReset}
|
|
title="Reset to default (${this.defaultKey})">
|
|
\u21BA
|
|
</button>
|
|
` : ''}
|
|
`;
|
|
}
|
|
}
|
|
|
|
declare global {
|
|
interface HTMLElementTagNameMap {
|
|
'shortcut-capture': ShortcutCapture;
|
|
}
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<done>shortcut-capture component renders a key badge, enters recording mode on click, captures keydown via buildKeyString, dispatches shortcut-change event, supports Escape cancel, and shows per-shortcut reset button when binding differs from default.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Add Keyboard Shortcuts section to config page with conflict detection</name>
|
|
<files>frontend/src/components/config-page/config-page.ts</files>
|
|
<action>
|
|
1. **Import required modules** at the top of config-page.ts:
|
|
```typescript
|
|
import './shortcut-capture';
|
|
import { shortcutsStore } from '../../store/shortcuts-store';
|
|
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
|
|
```
|
|
|
|
2. **Add ShortcutsController** to the component class:
|
|
```typescript
|
|
private shortcutsCtrl = new ShortcutsController(this);
|
|
```
|
|
|
|
3. **Define shortcut metadata** — a static map of action IDs to human-readable labels and categories. Add as a class property or module-level const:
|
|
```typescript
|
|
private static readonly SHORTCUT_META: Record<string, { label: string; category: string; scope: string; defaultKey: string }> = {
|
|
'player.playPause': { label: 'Play / Pause', category: 'Player', scope: 'global', defaultKey: 'Space' },
|
|
'player.next': { label: 'Next Track', category: 'Player', scope: 'global', defaultKey: 'N' },
|
|
'player.previous': { label: 'Previous Track', category: 'Player', scope: 'global', defaultKey: 'P' },
|
|
'player.volumeUp': { label: 'Volume Up', category: 'Player', scope: 'global', defaultKey: 'Up' },
|
|
'player.volumeDown': { label: 'Volume Down', category: 'Player', scope: 'global', defaultKey: 'Down' },
|
|
'player.seekForward': { label: 'Seek Forward', category: 'Player', scope: 'global', defaultKey: 'Right' },
|
|
'player.seekBack': { label: 'Seek Back', category: 'Player', scope: 'global', defaultKey: 'Left' },
|
|
'player.shuffle': { label: 'Toggle Shuffle', category: 'Player', scope: 'global', defaultKey: 'S' },
|
|
'player.repeat': { label: 'Cycle Repeat', category: 'Player', scope: 'global', defaultKey: 'R' },
|
|
'player.mute': { label: 'Toggle Mute', category: 'Player', scope: 'global', defaultKey: 'M' },
|
|
'nav.search': { label: 'Focus Search', category: 'Navigation', scope: 'global', defaultKey: '/' },
|
|
'nav.searchAlt': { label: 'Focus Search (Alt)', category: 'Navigation', scope: 'global', defaultKey: 'Ctrl+F' },
|
|
'nav.queue': { label: 'Toggle Queue', category: 'Navigation', scope: 'global', defaultKey: 'Q' },
|
|
'app.selectAll': { label: 'Select All', category: 'App', scope: 'global', defaultKey: 'Ctrl+A' },
|
|
'tracklist.play': { label: 'Play Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Enter' },
|
|
'tracklist.delete': { label: 'Remove Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Delete' },
|
|
};
|
|
```
|
|
|
|
4. **Add conflict detection state:**
|
|
```typescript
|
|
@state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null;
|
|
```
|
|
|
|
5. **Add shortcut change handler:**
|
|
```typescript
|
|
private async handleShortcutChange(e: CustomEvent<{ action: string; key: string }>) {
|
|
const { action, key } = e.detail;
|
|
|
|
// Check for conflict — find any other action with the same key in the same or overlapping scope
|
|
const meta = ConfigPage.SHORTCUT_META[action];
|
|
const conflict = shortcutsStore.findConflict(key, meta?.scope ?? 'global', action);
|
|
|
|
if (conflict) {
|
|
// Show conflict warning
|
|
this.shortcutConflict = {
|
|
newAction: action,
|
|
newKey: key,
|
|
existingAction: conflict.action,
|
|
};
|
|
return;
|
|
}
|
|
|
|
// No conflict — save directly
|
|
await shortcutsStore.updateBinding(action, key);
|
|
}
|
|
|
|
private async handleConflictOverwrite() {
|
|
if (!this.shortcutConflict) return;
|
|
const { newAction, newKey, existingAction } = this.shortcutConflict;
|
|
// Unbind the existing action
|
|
await shortcutsStore.updateBinding(existingAction, '');
|
|
// Set the new binding
|
|
await shortcutsStore.updateBinding(newAction, newKey);
|
|
this.shortcutConflict = null;
|
|
}
|
|
|
|
private handleConflictCancel() {
|
|
this.shortcutConflict = null;
|
|
}
|
|
|
|
private async handleResetAllShortcuts() {
|
|
await shortcutsStore.resetAll();
|
|
}
|
|
```
|
|
|
|
6. **Render the Keyboard Shortcuts section.** Add a new method `renderShortcutsSection()` and call it from the main render method. Place it as a new `<config-section>` after the existing sections (before or after Library section — find the natural insertion point):
|
|
|
|
```typescript
|
|
private renderShortcutsSection() {
|
|
const bindings = this.shortcutsCtrl.state.bindings;
|
|
const categories = ['Player', 'Navigation', 'App'];
|
|
|
|
return html`
|
|
<config-section label="Keyboard Shortcuts">
|
|
${categories.map(cat => {
|
|
const actions = Object.entries(ConfigPage.SHORTCUT_META)
|
|
.filter(([_, meta]) => meta.category === cat);
|
|
|
|
if (actions.length === 0) return '';
|
|
|
|
return html`
|
|
<div class="shortcut-category">
|
|
<div class="shortcut-category-header">${cat}</div>
|
|
${actions.map(([action, meta]) => html`
|
|
<div class="shortcut-row">
|
|
<span class="shortcut-label">
|
|
${meta.label}
|
|
${meta.scope !== 'global' ? html`
|
|
<span class="shortcut-scope">(${meta.scope.replace('panel:', '')})</span>
|
|
` : ''}
|
|
</span>
|
|
<shortcut-capture
|
|
.action=${action}
|
|
.currentKey=${bindings.get(action) ?? ''}
|
|
.defaultKey=${meta.defaultKey}
|
|
@shortcut-change=${this.handleShortcutChange}
|
|
></shortcut-capture>
|
|
</div>
|
|
`)}
|
|
</div>
|
|
`;
|
|
})}
|
|
|
|
<div class="shortcut-actions">
|
|
<button class="btn-ghost" @click=${this.handleResetAllShortcuts}>
|
|
Reset All to Defaults
|
|
</button>
|
|
</div>
|
|
|
|
${this.shortcutConflict ? html`
|
|
<div class="conflict-banner">
|
|
<span class="conflict-text">
|
|
<strong>${this.shortcutConflict.newKey}</strong> is already bound to
|
|
<strong>${ConfigPage.SHORTCUT_META[this.shortcutConflict.existingAction]?.label ?? this.shortcutConflict.existingAction}</strong>.
|
|
</span>
|
|
<div class="conflict-actions">
|
|
<button class="btn-warning" @click=${this.handleConflictOverwrite}>
|
|
Overwrite
|
|
</button>
|
|
<button class="btn-ghost" @click=${this.handleConflictCancel}>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
</config-section>
|
|
`;
|
|
}
|
|
```
|
|
|
|
7. **Call `renderShortcutsSection()`** from the main render method. Insert `${this.renderShortcutsSection()}` in the template — place it between "Track List Columns" and "Library" sections, or after Library. Look at the current render layout to find the best spot.
|
|
|
|
8. **Add CSS styles** for the shortcuts section:
|
|
```css
|
|
.shortcut-category {
|
|
margin-bottom: 16px;
|
|
}
|
|
.shortcut-category-header {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
font-weight: 600;
|
|
color: var(--yj-text-secondary, #aaa);
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 8px;
|
|
padding-bottom: 4px;
|
|
border-bottom: 1px solid var(--yj-border, #444);
|
|
}
|
|
.shortcut-row {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 6px 0;
|
|
gap: 16px;
|
|
}
|
|
.shortcut-label {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
color: var(--yj-text-primary, #eee);
|
|
}
|
|
.shortcut-scope {
|
|
font-size: var(--yj-text-xs, 11px);
|
|
color: var(--yj-text-tertiary, #888);
|
|
margin-left: 4px;
|
|
}
|
|
.shortcut-actions {
|
|
margin-top: 16px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
.conflict-banner {
|
|
margin-top: 12px;
|
|
padding: 12px;
|
|
background: rgba(255, 165, 0, 0.1);
|
|
border: 1px solid rgba(255, 165, 0, 0.4);
|
|
border-radius: 6px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
}
|
|
.conflict-text {
|
|
font-size: var(--yj-text-sm, 13px);
|
|
}
|
|
.conflict-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-shrink: 0;
|
|
}
|
|
```
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -20</automated>
|
|
</verify>
|
|
<done>Keyboard Shortcuts section renders in the config page with shortcuts grouped by category (Player, Navigation, App). Each row shows label + shortcut-capture widget. Conflict detection warns before overwriting. "Reset All to Defaults" and per-shortcut reset work. Panel-specific shortcuts show their scope label.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
cd frontend && npx tsc --noEmit
|
|
```
|
|
TypeScript compiles. shortcut-capture component and shortcuts section are properly wired.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- `shortcut-capture` component exists and handles recording, Escape cancel, blur cancel, reset
|
|
- Config page has a "Keyboard Shortcuts" section with category headers
|
|
- All 16 default shortcuts are listed with their labels
|
|
- Clicking a capture widget enters recording mode, pressing a key updates the binding
|
|
- Conflicts are detected and shown in a warning banner with Overwrite/Cancel options
|
|
- "Reset All to Defaults" button calls store.resetAll()
|
|
- Per-shortcut reset icon appears on hover when binding differs from default
|
|
- Panel-specific shortcuts show their scope (e.g., "track-list") next to the label
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md`
|
|
</output>
|