feat(shortcuts): tell the key story once, and give the arrows back
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.
This commit is contained in:
@@ -25,6 +25,10 @@ import { FavoritesController } from '@store/controllers/favorites-controller';
|
||||
import { GetAllPlaylists } from '@go/playlist/Service';
|
||||
import type { playlist } from '@go/models';
|
||||
import { Events } from '../../events';
|
||||
import {
|
||||
SHORTCUT_CATEGORIES,
|
||||
SHORTCUT_META,
|
||||
} from '../../services/shortcut-meta';
|
||||
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
|
||||
import type { ConfigFieldChangeEvent } from './config-field';
|
||||
import type { BackgroundShade } from '@store/theme-store';
|
||||
@@ -63,150 +67,6 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
// --- Shortcuts controller ---
|
||||
private shortcutsCtrl = new ShortcutsController(this);
|
||||
|
||||
// --- Shortcut metadata for UI grouping ---
|
||||
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',
|
||||
},
|
||||
'autotag.apply': {
|
||||
label: 'Apply Match',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'A',
|
||||
},
|
||||
'autotag.skip': {
|
||||
label: 'Skip Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'S',
|
||||
},
|
||||
'autotag.leave': {
|
||||
label: 'Leave As Is',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'L',
|
||||
},
|
||||
'autotag.paste': {
|
||||
label: 'Paste Release URL',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'U',
|
||||
},
|
||||
'autotag.search': {
|
||||
label: 'Search Candidates',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'F',
|
||||
},
|
||||
'autotag.next': {
|
||||
label: 'Next Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'Down',
|
||||
},
|
||||
'autotag.previous': {
|
||||
label: 'Previous Folder',
|
||||
category: 'Autotag',
|
||||
scope: 'panel:autotag',
|
||||
defaultKey: 'Up',
|
||||
},
|
||||
};
|
||||
|
||||
// --- Now Playing state ---
|
||||
@state() private scrollMode = 'hover';
|
||||
|
||||
@@ -1338,7 +1198,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
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 meta = SHORTCUT_META[action];
|
||||
const conflict = shortcutsStore.findConflict(
|
||||
key,
|
||||
meta?.scope ?? 'global',
|
||||
@@ -1901,11 +1761,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
private renderShortcutsSection() {
|
||||
const bindings =
|
||||
this.shortcutsCtrl.state.bindings;
|
||||
const categories = [
|
||||
'Player',
|
||||
'Navigation',
|
||||
'App',
|
||||
];
|
||||
// All four, from the shared table: the Autotag bindings are
|
||||
// persisted and rebindable like any other, and listing three of
|
||||
// four categories is how they came to be written down nowhere.
|
||||
const categories = SHORTCUT_CATEGORIES;
|
||||
|
||||
return html`
|
||||
<config-section
|
||||
@@ -1914,7 +1773,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
>
|
||||
${categories.map((cat) => {
|
||||
const actions = Object.entries(
|
||||
ConfigPage.SHORTCUT_META,
|
||||
SHORTCUT_META,
|
||||
).filter(
|
||||
([, meta]) =>
|
||||
meta.category === cat,
|
||||
@@ -1990,8 +1849,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
|
||||
>
|
||||
is already bound to
|
||||
<strong
|
||||
>${ConfigPage
|
||||
.SHORTCUT_META[
|
||||
>${SHORTCUT_META[
|
||||
this
|
||||
.shortcutConflict
|
||||
.existingAction
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* The key story, told once.
|
||||
*
|
||||
* The single-key global bindings are staying (plan 007, Decision 1),
|
||||
* and until now Settings was the only place they were written down —
|
||||
* three of the four categories of them, at that, so the autotag keys
|
||||
* were written down nowhere. `?` opens this from anywhere the app owns
|
||||
* the keyboard.
|
||||
*
|
||||
* It reads `services/shortcut-meta` for the labels and the *store* for
|
||||
* the keys, so a rebound key shows its new binding rather than the
|
||||
* default it shipped with.
|
||||
*
|
||||
* It is a `wa-dialog` for the reason every dialog in this app is: the
|
||||
* focus trap, Escape and focus restore come with it. Being help rather
|
||||
* than a question, it is a dialog in a host template rather than a
|
||||
* `confirmAction()` call.
|
||||
*/
|
||||
import { LitElement, css, html, nothing } from 'lit';
|
||||
import { customElement, query, state } from 'lit/decorators.js';
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import {
|
||||
SHORTCUT_CATEGORIES,
|
||||
SHORTCUT_META,
|
||||
} from '../../services/shortcut-meta';
|
||||
import { shortcutsStore } from '@store/shortcuts-store';
|
||||
|
||||
/** How a key string reads to a person: `Ctrl+F` is two keys. */
|
||||
function keyParts(key: string): string[] {
|
||||
return key.split('+');
|
||||
}
|
||||
|
||||
/** Where a binding applies, in the user's terms. A panel binding that
|
||||
* does not say so reads as broken everywhere else. */
|
||||
function scopeNote(scope: string): string {
|
||||
if (scope === 'panel:track-list') return 'in the track list';
|
||||
if (scope === 'panel:autotag') return 'on the Autotag page';
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
@customElement('shortcuts-overlay')
|
||||
export class ShortcutsOverlay extends LitElement {
|
||||
@query('wa-dialog') private dialog?: HTMLElement & { open: boolean };
|
||||
|
||||
@state() private isOpen = false;
|
||||
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-dialog::part(dialog) {
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
.category {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
.category:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5em;
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
padding: 0.25em 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--yj-text-sm, 0.8125rem);
|
||||
}
|
||||
|
||||
.scope {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.keys {
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
kbd {
|
||||
background: var(--yj-bg-elevated, #343a40);
|
||||
border: 1px solid var(--yj-border, #495057);
|
||||
border-radius: 4px;
|
||||
padding: 0.1em 0.45em;
|
||||
font-family: inherit;
|
||||
font-size: var(--yj-text-xs, 0.6875rem);
|
||||
}
|
||||
|
||||
.footnote {
|
||||
margin: 1em 0 0;
|
||||
font-size: var(--yj-text-xs, 0.6875rem);
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
document.addEventListener('shortcut:app-shortcuts', this.open);
|
||||
// The keys come from the backend, so the first open can arrive
|
||||
// before they do.
|
||||
this.unsubscribe = shortcutsStore.subscribe(() =>
|
||||
this.requestUpdate(),
|
||||
);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('shortcut:app-shortcuts', this.open);
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* `?` opens; Escape closes, as it does for every dialog here.
|
||||
*
|
||||
* It is deliberately not a toggle. A dialog owns the whole keyboard
|
||||
* while it is up — `focusedControlOwnsKey` yields every unmodified
|
||||
* key to anything inside one — so a second `?` never reaches the
|
||||
* service, and a toggle would be a promise the shortcut layer
|
||||
* cannot keep. Written after watching an e2e spec assert it and
|
||||
* fail.
|
||||
*/
|
||||
private open = (): void => {
|
||||
if (this.isOpen) return;
|
||||
|
||||
this.isOpen = true;
|
||||
void this.updateComplete.then(() => {
|
||||
if (this.dialog) this.dialog.open = true;
|
||||
});
|
||||
};
|
||||
|
||||
private close(): void {
|
||||
if (this.dialog) this.dialog.open = false;
|
||||
this.isOpen = false;
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.isOpen) return nothing;
|
||||
|
||||
const bindings = shortcutsStore.getBindings();
|
||||
|
||||
return html`
|
||||
<wa-dialog
|
||||
label="Keyboard Shortcuts"
|
||||
data-testid="shortcuts-overlay"
|
||||
@wa-hide=${() => this.close()}
|
||||
>
|
||||
${SHORTCUT_CATEGORIES.map((category) => {
|
||||
const rows = Object.entries(SHORTCUT_META).filter(
|
||||
([, meta]) => meta.category === category,
|
||||
);
|
||||
|
||||
if (rows.length === 0) return nothing;
|
||||
|
||||
// A note every row in the category repeats is a
|
||||
// note about the category. "— on the Autotag page"
|
||||
// seven times is noise; once is the heading.
|
||||
const first = scopeNote(rows[0]![1].scope);
|
||||
const uniform = rows.every(
|
||||
([, meta]) => scopeNote(meta.scope) === first,
|
||||
);
|
||||
// …and a note the *heading* already says is not a
|
||||
// note either: "Autotag — on the Autotag page".
|
||||
const shared =
|
||||
uniform &&
|
||||
!first
|
||||
.toLowerCase()
|
||||
.includes(category.toLowerCase())
|
||||
? first
|
||||
: '';
|
||||
|
||||
return html`
|
||||
<div class="category">
|
||||
<h3>
|
||||
${category}
|
||||
${shared
|
||||
? html`<span class="scope"
|
||||
>— ${shared}</span
|
||||
>`
|
||||
: nothing}
|
||||
</h3>
|
||||
${rows.map(([action, meta]) => {
|
||||
const key =
|
||||
bindings.get(action) ?? meta.defaultKey;
|
||||
const note =
|
||||
shared || uniform
|
||||
? ''
|
||||
: scopeNote(meta.scope);
|
||||
|
||||
return html`
|
||||
<div class="row">
|
||||
<span class="label">
|
||||
${meta.label}
|
||||
${note
|
||||
? html`<span class="scope"
|
||||
>— ${note}</span
|
||||
>`
|
||||
: nothing}
|
||||
</span>
|
||||
<span class="keys">
|
||||
${keyParts(key).map(
|
||||
(part) =>
|
||||
html`<kbd>${part}</kbd>`,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
<p class="footnote">
|
||||
Every one of these can be rebound in Settings →
|
||||
Keyboard Shortcuts.
|
||||
</p>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'shortcuts-overlay': ShortcutsOverlay;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user