461 lines
19 KiB
Markdown
461 lines
19 KiB
Markdown
---
|
|
phase: 09-scan-cancellation-keyboard-shortcuts
|
|
plan: 02
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- backend/shortcuts/config.go
|
|
- backend/config/config.go
|
|
- frontend/src/services/keyboard-shortcut-service.ts
|
|
- frontend/src/store/shortcuts-store.ts
|
|
- frontend/src/store/controllers/shortcuts-controller.ts
|
|
- frontend/src/store/index.ts
|
|
autonomous: true
|
|
requirements:
|
|
- KEY-01
|
|
- KEY-04
|
|
- KEY-05
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Default keyboard shortcuts work immediately — Space toggles play/pause, arrows adjust volume/seek, S/R/Q/M/N/P trigger actions"
|
|
- "Shortcuts are suppressed when a text input is focused (except Escape which blurs)"
|
|
- "Shortcuts are context-aware — panel-specific bindings (Enter/Delete in track list) only fire when that panel has focus"
|
|
- "Shortcut config persists to TOML via Wails bindings and survives app restart"
|
|
artifacts:
|
|
- path: "backend/shortcuts/config.go"
|
|
provides: "Shortcuts config package with defaults and validation"
|
|
exports: ["Config", "ApplyDefaults", "Validate", "DefaultBindings"]
|
|
- path: "frontend/src/services/keyboard-shortcut-service.ts"
|
|
provides: "Singleton keyboard shortcut service with scope resolution"
|
|
exports: ["keyboardShortcutService", "KeyboardShortcutService"]
|
|
- path: "frontend/src/store/shortcuts-store.ts"
|
|
provides: "Shortcuts store persisting bindings via Wails config"
|
|
exports: ["shortcutsStore", "ShortcutsStore"]
|
|
key_links:
|
|
- from: "frontend/src/services/keyboard-shortcut-service.ts"
|
|
to: "frontend/src/store/shortcuts-store.ts"
|
|
via: "Service reads bindings from store to resolve key combos to actions"
|
|
pattern: "shortcutsStore"
|
|
- from: "frontend/src/store/shortcuts-store.ts"
|
|
to: "backend/config/config.go"
|
|
via: "Wails bindings GetShortcuts/SetShortcuts for persistence"
|
|
pattern: "GetShortcuts|SetShortcuts"
|
|
- from: "frontend/src/services/keyboard-shortcut-service.ts"
|
|
to: "frontend/src/store/player-store.ts"
|
|
via: "Action dispatch calls store methods for player controls"
|
|
pattern: "playerStore|queueStore"
|
|
---
|
|
|
|
<objective>
|
|
Create the keyboard shortcuts backend config package and the frontend keyboard shortcut service with default bindings, scope resolution, and action dispatch.
|
|
|
|
Purpose: Foundation for KEY-01/04/05 — shortcuts work out of the box. Settings UI (KEY-02/03) wires to this in Plan 04.
|
|
Output: Go shortcuts config, frontend service singleton, shortcuts store with Wails persistence.
|
|
</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
|
|
|
|
@backend/config/config.go
|
|
@backend/theme/config.go
|
|
@frontend/src/store/index.ts
|
|
@frontend/src/store/theme-store.ts
|
|
@frontend/src/store/player-store.ts
|
|
@frontend/src/store/queue-store.ts
|
|
|
|
<interfaces>
|
|
<!-- Config struct pattern (config.go:24-33) -->
|
|
type Config struct {
|
|
ctx context.Context
|
|
logger *slog.Logger
|
|
filePath string
|
|
Library *library.Config `toml:"Library"`
|
|
Theme *theme.Config `toml:"Theme"`
|
|
Window *WindowConfig `toml:"Window"`
|
|
TrackList *tracklist.Config `toml:"TrackList"`
|
|
Favorites *favorites.Config `toml:"Favorites"`
|
|
}
|
|
|
|
<!-- Config section pattern (theme/config.go) — follow this exactly -->
|
|
type Config struct {
|
|
AccentColor string `toml:"AccentColor"`
|
|
BackgroundShade BackgroundShade `toml:"BackgroundShade"`
|
|
}
|
|
func (c *Config) ApplyDefaults() { ... }
|
|
func (c *Config) Validate() error { ... }
|
|
|
|
<!-- Store pattern (from existing stores) -->
|
|
class ThemeStore {
|
|
private state: ThemeState;
|
|
private subscribers = new Set<(state: ThemeState) => void>();
|
|
subscribe(cb: (state: ThemeState) => void): () => void { ... }
|
|
private notify() { queueMicrotask(() => { ... }) }
|
|
}
|
|
export const themeStore = new ThemeStore();
|
|
|
|
<!-- Player store actions that shortcuts will call -->
|
|
// From player-store.ts:
|
|
export const playerStore: { togglePlayback(), setVolume(v: number), seek(pos: number) }
|
|
// From queue-store.ts:
|
|
export const queueStore: { next(), previous(), toggleShuffle(), cycleRepeat() }
|
|
|
|
<!-- Store index exports (store/index.ts) -->
|
|
export { playerStore } from './player-store';
|
|
export { queueStore } from './queue-store';
|
|
export { themeStore } from './theme-store';
|
|
export { searchStore } from './search-store';
|
|
|
|
<!-- Events pattern for config changes -->
|
|
const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here
|
|
</interfaces>
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto">
|
|
<name>Task 1: Create backend shortcuts config package and wire into main config</name>
|
|
<files>backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts</files>
|
|
<action>
|
|
1. Create `backend/shortcuts/config.go`:
|
|
|
|
```go
|
|
package shortcuts
|
|
|
|
// Config holds user-customized keyboard shortcut bindings.
|
|
// Keys are action IDs (e.g. "player.playPause"), values are
|
|
// key combo strings in canonical format (e.g. "Ctrl+F", "Space").
|
|
type Config struct {
|
|
Bindings map[string]string `toml:"Bindings"`
|
|
}
|
|
|
|
// DefaultBindings returns the default keyboard shortcut bindings.
|
|
// Follows hybrid style: Space/arrows for player, Ctrl+key for app actions.
|
|
func DefaultBindings() map[string]string {
|
|
return map[string]string{
|
|
// Player controls (Global scope, no modifier)
|
|
"player.playPause": "Space",
|
|
"player.next": "N",
|
|
"player.previous": "P",
|
|
"player.volumeUp": "Up",
|
|
"player.volumeDown": "Down",
|
|
"player.seekForward": "Right",
|
|
"player.seekBack": "Left",
|
|
"player.shuffle": "S",
|
|
"player.repeat": "R",
|
|
"player.mute": "M",
|
|
|
|
// Navigation (Global scope)
|
|
"nav.search": "/",
|
|
"nav.searchAlt": "Ctrl+F",
|
|
"nav.queue": "Q",
|
|
|
|
// App actions (Global scope, Ctrl modifier)
|
|
"app.selectAll": "Ctrl+A",
|
|
|
|
// Panel-specific (track list)
|
|
"tracklist.play": "Enter",
|
|
"tracklist.delete": "Delete",
|
|
}
|
|
}
|
|
|
|
// ApplyDefaults fills any missing bindings with defaults.
|
|
// Existing user customizations are preserved.
|
|
func (c *Config) ApplyDefaults() {
|
|
if c.Bindings == nil {
|
|
c.Bindings = DefaultBindings()
|
|
return
|
|
}
|
|
|
|
defaults := DefaultBindings()
|
|
for action, key := range defaults {
|
|
if _, exists := c.Bindings[action]; !exists {
|
|
c.Bindings[action] = key
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate checks that the config is well-formed.
|
|
func (c *Config) Validate() error {
|
|
c.ApplyDefaults()
|
|
// No validation errors possible — any string is a valid binding.
|
|
// Conflict detection is a frontend UX concern, not a config error.
|
|
return nil
|
|
}
|
|
```
|
|
|
|
2. In `backend/config/config.go`:
|
|
- Add import: `"yellowjacket/backend/shortcuts"`
|
|
- Add field to Config struct: `Shortcuts *shortcuts.Config \`toml:"Shortcuts"\``
|
|
- In `applyDefaults()`, add:
|
|
```go
|
|
if c.Shortcuts == nil {
|
|
c.Shortcuts = &shortcuts.Config{}
|
|
}
|
|
c.Shortcuts.ApplyDefaults()
|
|
```
|
|
- In `Validate()`, add validation for Shortcuts (after the Favorites block):
|
|
```go
|
|
if c.Shortcuts != nil {
|
|
if err := c.Shortcuts.Validate(); err != nil {
|
|
configErrs = errors.Join(configErrs, err)
|
|
}
|
|
}
|
|
```
|
|
- Add Wails binding methods:
|
|
```go
|
|
// GetShortcuts returns the current shortcut bindings map.
|
|
func (c *Config) GetShortcuts() map[string]string {
|
|
if c.Shortcuts == nil {
|
|
c.Shortcuts = &shortcuts.Config{}
|
|
c.Shortcuts.ApplyDefaults()
|
|
}
|
|
return c.Shortcuts.Bindings
|
|
}
|
|
|
|
// SetShortcuts saves the entire shortcut bindings map.
|
|
func (c *Config) SetShortcuts(bindings map[string]string) error {
|
|
if c.Shortcuts == nil {
|
|
c.Shortcuts = &shortcuts.Config{}
|
|
}
|
|
c.Shortcuts.Bindings = bindings
|
|
|
|
if err := c.Save(); err != nil {
|
|
return fmt.Errorf("could not save shortcuts config: %w", err)
|
|
}
|
|
|
|
if c.ctx != nil {
|
|
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, bindings)
|
|
}
|
|
|
|
c.logger.Info("shortcuts config updated")
|
|
return nil
|
|
}
|
|
|
|
// SetShortcut saves a single shortcut binding.
|
|
func (c *Config) SetShortcut(action string, key string) error {
|
|
if c.Shortcuts == nil {
|
|
c.Shortcuts = &shortcuts.Config{}
|
|
c.Shortcuts.ApplyDefaults()
|
|
}
|
|
c.Shortcuts.Bindings[action] = key
|
|
|
|
if err := c.Save(); err != nil {
|
|
return fmt.Errorf("could not save shortcut: %w", err)
|
|
}
|
|
|
|
if c.ctx != nil {
|
|
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings)
|
|
}
|
|
|
|
c.logger.Info("shortcut updated", "action", action, "key", key)
|
|
return nil
|
|
}
|
|
|
|
// ResetShortcuts resets all shortcuts to defaults.
|
|
func (c *Config) ResetShortcuts() error {
|
|
c.Shortcuts = &shortcuts.Config{
|
|
Bindings: shortcuts.DefaultBindings(),
|
|
}
|
|
|
|
if err := c.Save(); err != nil {
|
|
return fmt.Errorf("could not save shortcuts reset: %w", err)
|
|
}
|
|
|
|
if c.ctx != nil {
|
|
runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings)
|
|
}
|
|
|
|
c.logger.Info("shortcuts reset to defaults")
|
|
return nil
|
|
}
|
|
```
|
|
|
|
3. Add `ShortcutsConfigChanged` event to `backend/events/events.go` in the Config events block:
|
|
```go
|
|
ShortcutsConfigChanged = "ShortcutsConfigChanged"
|
|
```
|
|
|
|
4. Run `go generate ./backend/events/...` to sync to TypeScript.
|
|
</action>
|
|
<verify>
|
|
<automated>cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts</automated>
|
|
</verify>
|
|
<done>Shortcuts config package exists with defaults matching user decisions. Config.go has Shortcuts field, getter/setter Wails bindings, and emits ShortcutsConfigChanged. Event synced to TypeScript.</done>
|
|
</task>
|
|
|
|
<task type="auto">
|
|
<name>Task 2: Create frontend keyboard shortcut service, store, and controller</name>
|
|
<files>frontend/src/services/keyboard-shortcut-service.ts, frontend/src/store/shortcuts-store.ts, frontend/src/store/controllers/shortcuts-controller.ts, frontend/src/store/index.ts</files>
|
|
<action>
|
|
1. Create `frontend/src/services/keyboard-shortcut-service.ts`:
|
|
|
|
This is the FIRST file in the `services/` directory — create the directory.
|
|
|
|
The service is a singleton that:
|
|
- Listens on `document.addEventListener('keydown', ...)` in constructor
|
|
- Resolves the active scope by walking the shadow DOM active element chain
|
|
- Looks up the key combo in the shortcuts store
|
|
- Dispatches the action by calling the appropriate store method
|
|
|
|
Key implementation details:
|
|
- **Key string builder:** `buildKeyString(e: KeyboardEvent): string`
|
|
- Modifiers in fixed order: Ctrl (includes Meta on Mac) + Alt + Shift
|
|
- Skip bare modifier presses (return '' for Control, Alt, Shift, Meta)
|
|
- Normalize: ArrowUp→Up, ArrowDown→Down, ArrowLeft→Left, ArrowRight→Right, ' '→Space
|
|
- Single-char keys: uppercase (e.g., 's' → 'S')
|
|
|
|
- **Shadow DOM active element:** `getDeepActiveElement(): Element | null`
|
|
- Walk `el.shadowRoot.activeElement` chain recursively
|
|
|
|
- **isTextInputFocused():** Check deep active element — if tagName is INPUT (type text/search/url/email/password/number/tel), TEXTAREA, or isContentEditable → true
|
|
|
|
- **resolveScope():** Returns 'text-input' | 'panel:track-list' | 'panel:queue' | 'global'
|
|
- First check isTextInputFocused → 'text-input'
|
|
- Walk up from deep active element checking closest('[data-shortcut-scope]') attribute
|
|
- If found, return `panel:${value}`
|
|
- Default: 'global'
|
|
|
|
- **handleKeydown logic:**
|
|
1. If scope is 'text-input': only allow Escape (blur the active element), suppress everything else — return early
|
|
2. Build key string
|
|
3. Get bindings from shortcutsStore
|
|
4. First try panel-specific match: find binding where action starts with panel prefix AND key matches
|
|
5. Then try global match: find binding where action does NOT start with any panel prefix AND key matches
|
|
6. If match found: preventDefault, dispatch action
|
|
|
|
- **dispatch(action: string):** Switch on action ID to call store methods:
|
|
- `player.playPause` → `playerStore.togglePlayback()`
|
|
- `player.next` → `queueStore.next()`
|
|
- `player.previous` → `queueStore.previous()`
|
|
- `player.volumeUp` → `playerStore.adjustVolume(5)` (add adjustVolume method if not exists, or use setVolume with current + 5)
|
|
- `player.volumeDown` → `playerStore.adjustVolume(-5)`
|
|
- `player.seekForward` → `playerStore.seekRelative(5)` (add seekRelative if needed, or use seek with current + 5)
|
|
- `player.seekBack` → `playerStore.seekRelative(-5)`
|
|
- `player.shuffle` → `queueStore.toggleShuffle()`
|
|
- `player.repeat` → `queueStore.cycleRepeat()`
|
|
- `player.mute` → `playerStore.toggleMute()`
|
|
- `nav.search`, `nav.searchAlt` → Focus search box: `document.querySelector('search-bar')?.shadowRoot?.querySelector('input')?.focus()` (walk shadow DOM to find the input)
|
|
- `nav.queue` → Toggle queue visibility (dispatch a custom event or call a store method)
|
|
- `app.selectAll` → `document.execCommand('selectAll')` or dispatch to active panel
|
|
- `tracklist.play` → Dispatch custom event `shortcut:tracklist-play` on document
|
|
- `tracklist.delete` → Dispatch custom event `shortcut:tracklist-delete` on document
|
|
|
|
Export `buildKeyString` as a named export (needed by shortcut-capture widget in Plan 04).
|
|
Export the singleton: `export const keyboardShortcutService = new KeyboardShortcutService();`
|
|
|
|
Note on volume/seek: Check the actual player-store API. If `adjustVolume(delta)` doesn't exist, the service should read current volume from playerStore state, add the delta, clamp to 0-100, and call `SetVolume()` via Wails binding. Same for seek: read current position, add delta seconds, call `Seek()`. Use the Wails-generated bindings directly (e.g., `import { SetVolume, Seek } from '../../wailsjs/go/player/Player'` — check the actual import path).
|
|
|
|
2. Create `frontend/src/store/shortcuts-store.ts`:
|
|
|
|
Follow existing store pattern (class-based singleton with subscribe/notify):
|
|
```typescript
|
|
interface ShortcutBinding {
|
|
action: string;
|
|
key: string;
|
|
scope: 'global' | string; // 'global' or 'panel:track-list' etc.
|
|
category: 'Player' | 'Navigation' | 'App';
|
|
}
|
|
|
|
interface ShortcutsState {
|
|
bindings: Map<string, string>; // action → key combo
|
|
loaded: boolean;
|
|
}
|
|
```
|
|
|
|
- Constructor: call `GetShortcuts()` Wails binding to load initial state. Listen for `ShortcutsConfigChanged` event to update.
|
|
- `getBindings(): Map<string, string>` — returns current bindings
|
|
- `getKeyForAction(action: string): string` — lookup
|
|
- `getActionForKey(key: string, scope?: string): string | undefined` — reverse lookup (for the service). Check panel-specific scope first, then global.
|
|
- `updateBinding(action: string, key: string): Promise<void>` — calls `SetShortcut()` Wails binding
|
|
- `resetAll(): Promise<void>` — calls `ResetShortcuts()` Wails binding
|
|
- `findConflict(key: string, scope: string, excludeAction: string): { action: string, key: string } | null` — for conflict detection
|
|
|
|
Use `queueMicrotask` coalescing for notify (match existing pattern).
|
|
|
|
3. Create `frontend/src/store/controllers/shortcuts-controller.ts`:
|
|
|
|
Follow existing controller pattern (ReactiveController bridging store to LitElement):
|
|
```typescript
|
|
import { ReactiveController, ReactiveControllerHost } from 'lit';
|
|
import { shortcutsStore, ShortcutsState } from '../shortcuts-store';
|
|
|
|
export class ShortcutsController implements ReactiveController {
|
|
host: ReactiveControllerHost;
|
|
state: ShortcutsState;
|
|
private unsubscribe?: () => void;
|
|
|
|
constructor(host: ReactiveControllerHost) {
|
|
this.host = host;
|
|
this.state = shortcutsStore.getState();
|
|
host.addController(this);
|
|
}
|
|
|
|
hostConnected() {
|
|
this.unsubscribe = shortcutsStore.subscribe((state) => {
|
|
this.state = state;
|
|
this.host.requestUpdate();
|
|
});
|
|
}
|
|
|
|
hostDisconnected() {
|
|
this.unsubscribe?.();
|
|
}
|
|
}
|
|
```
|
|
|
|
4. Update `frontend/src/store/index.ts` — add exports:
|
|
```typescript
|
|
export { shortcutsStore } from './shortcuts-store';
|
|
export { ShortcutsController } from './controllers/shortcuts-controller';
|
|
```
|
|
|
|
5. Initialize the keyboard shortcut service. The service must be created once at app startup. Find where other singletons are initialized (likely in `frontend/src/index.ts` or the main app component). Import and reference the singleton to ensure it's instantiated:
|
|
```typescript
|
|
import { keyboardShortcutService } from './services/keyboard-shortcut-service';
|
|
```
|
|
The import alone triggers instantiation since the module exports a `new KeyboardShortcutService()` at module scope.
|
|
</action>
|
|
<verify>
|
|
<automated>cd frontend && npx tsc --noEmit 2>&1 | head -30</automated>
|
|
</verify>
|
|
<done>Keyboard shortcut service listens for keydown events and dispatches actions based on scope. Shortcuts store loads bindings from Go config. Default shortcuts work: Space=play/pause, arrows=volume/seek, S/R/Q/M/N/P=player actions, /+Ctrl+F=search, Enter/Delete=tracklist panel. Text input suppression works (Escape only). Controller available for Lit components.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<verification>
|
|
```bash
|
|
cd backend && go build ./... && go vet ./...
|
|
cd ../frontend && npx tsc --noEmit
|
|
```
|
|
Both backend and frontend compile. Shortcuts config persists through TOML. Service initializes at startup.
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Go `shortcuts` package exists with `Config`, `ApplyDefaults`, `Validate`, `DefaultBindings`
|
|
- Config.go has `Shortcuts` field, `GetShortcuts`, `SetShortcuts`, `SetShortcut`, `ResetShortcuts` methods
|
|
- `ShortcutsConfigChanged` event exists and is synced to TypeScript
|
|
- Frontend `KeyboardShortcutService` singleton listens on `document.keydown`
|
|
- Shadow DOM active element resolution works (recursive walk)
|
|
- Text input suppression: only Escape passes through
|
|
- Scope resolution: text-input > panel-specific > global
|
|
- Default bindings match user decisions: Space, arrows, S, R, Q, M, N, P, /, Ctrl+F, Ctrl+A, Enter, Delete
|
|
- ShortcutsStore loads from Wails binding and subscribes to change events
|
|
- ShortcutsController bridges store to Lit components
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md`
|
|
</output>
|