19 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 09-scan-cancellation-keyboard-shortcuts | 02 | execute | 1 |
|
true |
|
|
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.
<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>
@.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
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"` }type Config struct {
AccentColor string toml:"AccentColor"
BackgroundShade BackgroundShade toml:"BackgroundShade"
}
func (c *Config) ApplyDefaults() { ... }
func (c *Config) Validate() error { ... }
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();
// 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() }
export { playerStore } from './player-store'; export { queueStore } from './queue-store'; export { themeStore } from './theme-store'; export { searchStore } from './search-store';
const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here
Task 1: Create backend shortcuts config package and wire into main config backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts 1. Create `backend/shortcuts/config.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
}
-
In
backend/config/config.go:- Add import:
"yellowjacket/backend/shortcuts" - Add field to Config struct:
Shortcuts *shortcuts.Config \toml:"Shortcuts"`` - In
applyDefaults(), add:if c.Shortcuts == nil { c.Shortcuts = &shortcuts.Config{} } c.Shortcuts.ApplyDefaults() - In
Validate(), add validation for Shortcuts (after the Favorites block):if c.Shortcuts != nil { if err := c.Shortcuts.Validate(); err != nil { configErrs = errors.Join(configErrs, err) } } - Add Wails binding methods:
// 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 }
- Add import:
-
Add
ShortcutsConfigChangedevent tobackend/events/events.goin the Config events block:ShortcutsConfigChanged = "ShortcutsConfigChanged" -
Run
go generate ./backend/events/...to sync to TypeScript. cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts 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.
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.activeElementchain recursively
- Walk
-
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:
- If scope is 'text-input': only allow Escape (blur the active element), suppress everything else — return early
- Build key string
- Get bindings from shortcutsStore
- First try panel-specific match: find binding where action starts with panel prefix AND key matches
- Then try global match: find binding where action does NOT start with any panel prefix AND key matches
- 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 paneltracklist.play→ Dispatch custom eventshortcut:tracklist-playon documenttracklist.delete→ Dispatch custom eventshortcut:tracklist-deleteon 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).
-
Create
frontend/src/store/shortcuts-store.ts:Follow existing store pattern (class-based singleton with subscribe/notify):
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 forShortcutsConfigChangedevent to update. getBindings(): Map<string, string>— returns current bindingsgetKeyForAction(action: string): string— lookupgetActionForKey(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>— callsSetShortcut()Wails bindingresetAll(): Promise<void>— callsResetShortcuts()Wails bindingfindConflict(key: string, scope: string, excludeAction: string): { action: string, key: string } | null— for conflict detection
Use
queueMicrotaskcoalescing for notify (match existing pattern). - Constructor: call
-
Create
frontend/src/store/controllers/shortcuts-controller.ts:Follow existing controller pattern (ReactiveController bridging store to LitElement):
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?.(); } } -
Update
frontend/src/store/index.ts— add exports:export { shortcutsStore } from './shortcuts-store'; export { ShortcutsController } from './controllers/shortcuts-controller'; -
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.tsor the main app component). Import and reference the singleton to ensure it's instantiated:import { keyboardShortcutService } from './services/keyboard-shortcut-service';The import alone triggers instantiation since the module exports a
new KeyboardShortcutService()at module scope. cd frontend && npx tsc --noEmit 2>&1 | head -30 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.
<success_criteria>
- Go
shortcutspackage exists withConfig,ApplyDefaults,Validate,DefaultBindings - Config.go has
Shortcutsfield,GetShortcuts,SetShortcuts,SetShortcut,ResetShortcutsmethods ShortcutsConfigChangedevent exists and is synced to TypeScript- Frontend
KeyboardShortcutServicesingleton listens ondocument.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>