Decision 1 keeps the unmodified single-key bindings, and Settings was the only place they were written down — three of the four categories of them, because config-page listed the categories by hand, so the autotag keys were written down nowhere at all. `?` now opens an overlay from anywhere the app owns the keyboard, and both surfaces read one table (services/shortcut-meta.ts, moved out of config-page's private static). The other half is the same explanation from the other side. Phase 1 gave the arrow keys to the grid, correctly — but all six of them, and no list in this app moves horizontally: track-list's own handler and utils/roving-rows both take Up/Down/Home/End and ignore Left/Right. So seeking stopped working from a focused row and nothing gained the keys. Reproduced in the running app: two ArrowRights on a focused track row, zero Player.Seek calls, against one per press from the body. A shifted character no longer reports Shift, so the binding is `?` and not `Shift+?` — the character already carries the shift, and a layout where it does not is a layout where "Shift+?" is wrong anyway.
85 lines
2.7 KiB
Go
85 lines
2.7 KiB
Go
// Package shortcuts manages keyboard shortcut configuration.
|
|
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
|
|
"app.selectAll": "Ctrl+A",
|
|
"app.shortcuts": "?",
|
|
|
|
// Panel-specific (track list). There is no `tracklist.delete`:
|
|
// it was bound to Delete and advertised in Settings as
|
|
// configurable while nothing listened for it, because "remove
|
|
// from library" does not exist and it is not clear what it would
|
|
// remove — the row (which the next scan puts back unless the path
|
|
// is also excluded) or the file (a delete-your-music button one
|
|
// keystroke from a focused row). Advertise it again when it does
|
|
// something.
|
|
"tracklist.play": "Enter",
|
|
|
|
// Panel-specific (autotag review). These are the keys the
|
|
// autotag page used to bind on its own document listener, which
|
|
// fired from every other page and could not arbitrate with the
|
|
// global bindings above. As panel bindings they apply only
|
|
// while that page is the one on screen.
|
|
"autotag.apply": "A",
|
|
"autotag.skip": "S",
|
|
"autotag.leave": "L",
|
|
"autotag.paste": "U",
|
|
"autotag.search": "F",
|
|
"autotag.next": "Down",
|
|
"autotag.previous": "Up",
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|