The history stack has been global since the Android back gesture landed -- every navigation is an entry and `popstate` restores any of them in either direction. What the report describes as "back is tab-scoped" is that the only way back was a detail view's own button, which leaves the screen with the view it belongs to: click over to Tracks and the album you were reading is still one entry away with nothing on screen saying so. `<nav-history>` is that affordance, plus `nav.back` / `nav.forward` on Alt+Left / Alt+Right -- the browser's own combination, and clear of the bare arrows that seek, since a binding matches on its full canonical string. Forward is not back negated, which is why the old `pushedEntries` counter is gone rather than extended: `popstate` carries no direction and fires identically both ways, so one counter decremented on every pop reads a forward as a second back. Each entry carries its index and the shell keeps the current one and a high-water mark, which also survives a jump of more than one. The buttons dispatch the events the rest of the app already dispatches rather than calling `history` themselves -- the shell owns the guard that stops a press at the root leaving the app, and a second caller reaching for history is how the old `navStack` came to disagree with the platform. Below 900px the control stands down: the top bar is what runs out of room first below that, and nothing becomes unreachable -- the shortcuts are global at every width and the phone has the platform's gesture. Closes #6
95 lines
3.2 KiB
Go
95 lines
3.2 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). Back and forward are the browser's
|
|
// own combination on every platform, which is the whole design
|
|
// brief for them: the app has one global history and this is the
|
|
// gesture people already have for it. The modifier is what keeps
|
|
// them clear of `player.seekBack`/`seekForward`, which are the
|
|
// bare arrows -- a binding is matched on its full canonical
|
|
// string, so "Alt+Left" and "Left" are different keys and not a
|
|
// conflict.
|
|
"nav.search": "/",
|
|
"nav.searchAlt": "Ctrl+F",
|
|
"nav.queue": "Q",
|
|
"nav.back": "Alt+Left",
|
|
"nav.forward": "Alt+Right",
|
|
|
|
// App actions
|
|
"app.selectAll": "Ctrl+A",
|
|
"app.shortcuts": "?",
|
|
|
|
// Panel-specific (track list). `tracklist.delete` spent six
|
|
// phases advertised in Settings with nothing on the other end of
|
|
// it, because "remove from library" did not exist and it was not
|
|
// clear what it would remove. It now removes the row and
|
|
// excludes the path from future scans, and leaves the file on
|
|
// disk — and the key only *opens the confirmation*, never
|
|
// performs the removal, which is the only version defensible one
|
|
// keystroke from a focused row.
|
|
"tracklist.play": "Enter",
|
|
"tracklist.delete": "Delete",
|
|
|
|
// 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
|
|
}
|