From ae220457bab482d8e4a87a8f48a05a50b30aaad8 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:48:59 +0000 Subject: [PATCH 01/11] fix(keys): a page or view that loads no longer takes the keyboard (KS-16) Electron gives a web contents the keyboard whenever its page commits a navigation (focusOnNavigation). That held for a tab hidden behind another and for one that never had the keyboard, so a slow page finishing its load took the keys from the open command bar, from a sidebar field being typed in, or from the page in front (keys typed after it went to the page that loaded). Every web contents Orbit makes now turns it off: tab pages, pages a tab or Peek opens with window.open, and Orbit's own views (uiWebPreferences). Orbit already gives a page the keyboard itself when the user selects, opens or clicks it. keys.spec.ts types where the keyboard is while a slow page commits; the three tests fail with focusOnNavigation on and pass with it off. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 6 +-- docs/feature-status.json | 1 + src/main/keys/focus.ts | 12 +++++ src/main/peek/index.ts | 2 + src/main/tabs/commands.ts | 2 + src/main/tabs/runtime.ts | 4 ++ src/main/windows.ts | 3 ++ tests/e2e/keys.spec.ts | 94 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 121 insertions(+), 3 deletions(-) create mode 100644 src/main/keys/focus.ts create mode 100644 tests/e2e/keys.spec.ts diff --git a/FEATURES.md b/FEATURES.md index 135ecf10..fa1b5af9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,11 +4,11 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 201 built (71 partial) · 280 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 201 built (72 partial) · 281 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 131 | 158 | +| P0 | 174 | 131 | 159 | | P1 | 221 | 61 | 111 | | P2 | 80 | 9 | 11 | @@ -361,7 +361,7 @@ Spec: `spec/shortcuts.md` | KS-13 | Developer tools | P1 | – | – | – | | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | | KS-15 | Standard app and window keys | P1 | – | – | – | | -| KS-16 | Text-editing keys keep their meaning | P0 | – | – | – | | +| KS-16 | Text-editing keys keep their meaning | P0 | 🟡 partial | ✅ e2e | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | – | – | – | | | KS-18 | Shortcut hints everywhere | P1 | – | – | – | | | KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e | – | command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes | diff --git a/docs/feature-status.json b/docs/feature-status.json index eefdb856..79bf8a39 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -136,6 +136,7 @@ "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, "KS-19": {"built": "partial", "note": "command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes"}, + "KS-16": {"built": "partial", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, "FL-14": {"built": "yes", "note": "theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade"}, "FL-16": {"built": "yes", "note": "motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window"}, diff --git a/src/main/keys/focus.ts b/src/main/keys/focus.ts new file mode 100644 index 00000000..383b839f --- /dev/null +++ b/src/main/keys/focus.ts @@ -0,0 +1,12 @@ +/** + * KS-16: the keyboard moves only when the user asks. + * + * Electron hands a web contents the keyboard each time its page commits a navigation + * (`focusOnNavigation`, on unless turned off). That holds for a page hidden behind another tab + * and for one that never had the keyboard, so a slow page finishing its load took the keyboard + * from the open command bar, from a field in the sidebar, or from the page being typed in, and + * the keys after it went to that page. Every web contents Orbit makes for its windows (pages and + * Orbit's own views) turns it off: Orbit gives a page the keyboard itself when the user selects, + * opens or clicks it. + */ +export const KEEP_KEYBOARD = { focusOnNavigation: false } as const satisfies Electron.WebPreferences diff --git a/src/main/peek/index.ts b/src/main/peek/index.ts index 39c3f991..4c42e002 100644 --- a/src/main/peek/index.ts +++ b/src/main/peek/index.ts @@ -15,6 +15,7 @@ import { layoutLeaves, MAX_PANES, SPLIT_FULL_MESSAGE } from '../../shared/split' import type { OrbitState, SpaceId, Tab, TabId, WindowId } from '../../shared/state' import { insertItem, sectionOf, splitOfTab } from '../../shared/tree' import type { MainContext } from '../context' +import { KEEP_KEYBOARD } from '../keys/focus' import { openMiniWindow } from '../miniwindow' import type { CallContext } from '../registry' import { activateInDraft, todayInsertIndex } from '../tabs/model' @@ -649,6 +650,7 @@ export function registerPeek(ctx: MainContext): void { return { action: 'allow', outlivesOpener: true, + overrideBrowserWindowOptions: { webPreferences: KEEP_KEYBOARD }, createWindow: (options) => { const guest = (options as { webContents?: WebContents }).webContents let created: WebContents | undefined diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 4b8720ff..56db6f44 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -24,6 +24,7 @@ import { } from '../../shared/tree' import { engineForWindow, parseInput } from '../../shared/url' import type { MainContext } from '../context' +import { KEEP_KEYBOARD } from '../keys/focus' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' import { TabMaintenance } from './maintenance' @@ -465,6 +466,7 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM return { action: 'allow', outlivesOpener: true, + overrideBrowserWindowOptions: { webPreferences: KEEP_KEYBOARD }, createWindow: (options) => { const windowId = windows.windowOfTab(openerTabId) ?? windows.focusedWindowId() const spaceId = windowId ? ops.spaceOfWindow(windowId) : opener.spaceId diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 7afb3a84..7f84451c 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -15,6 +15,7 @@ import { isPrivateProfileId } from '../../shared/private' import type { Tab, TabId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' import type { MainContext } from '../context' +import { KEEP_KEYBOARD } from '../keys/focus' import { showPageContextMenu } from './context-menu' import { FaviconStore } from './favicon-store' import { isIco, readIco } from './ico' @@ -213,6 +214,8 @@ export class TabRuntime { scrollBounce: true, navigateOnDragDrop: false, safeDialogs: true, + // A page that loads never takes the keyboard (KS-16). + ...KEEP_KEYBOARD, }, }) this.adopt(tabId, view) @@ -538,6 +541,7 @@ export class TabRuntime { return { action: 'allow', outlivesOpener: true, + overrideBrowserWindowOptions: { webPreferences: KEEP_KEYBOARD }, createWindow: (options) => { const guest = (options as { webContents?: WebContents }).webContents // No page after all (only links the browser opens come without one): Orbit loads it. diff --git a/src/main/windows.ts b/src/main/windows.ts index 4a900879..e2980f32 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -17,6 +17,7 @@ import type { import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' +import { KEEP_KEYBOARD } from './keys/focus' import { focusIsEditable } from './nav/focus' import { fastSwitchPanes } from './split/layout' @@ -168,6 +169,8 @@ export function uiWebPreferences(windowId: WindowId, view: RendererView): Electr nodeIntegration: false, spellcheck: false, additionalArguments: [`--orbit-window-id=${windowId}`, `--orbit-view=${view}`], + // A view that (re)loads never takes the keyboard (KS-16). + ...KEEP_KEYBOARD, } } diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts new file mode 100644 index 00000000..6dc0513d --- /dev/null +++ b/tests/e2e/keys.spec.ts @@ -0,0 +1,94 @@ +import { expect, test } from '@playwright/test' +import { launchOrbit, MOD, type Orbit, run, state, viewPage } from './helpers' +import { cmd, type NavFixtures, openTab, startNavFixtures } from './nav-support' + +let fx: NavFixtures +let orbit: Orbit + +test.beforeAll(async () => { + fx = await startNavFixtures() +}) +test.afterAll(async () => { + await fx.close() +}) +test.beforeEach(async () => { + orbit = await launchOrbit() +}) +test.afterEach(async () => { + await orbit.app.close() +}) + +/** + * Types each key wherever the keyboard is, as the keyboard does (to the web contents that has + * focus, not to a page the test picks); returns the URL of what took each key. + */ +function typeAnywhere(keys: string[]): Promise { + return orbit.app.evaluate( + ({ webContents }, list) => + list.map((key) => { + const contents = webContents.getFocusedWebContents() + if (!contents) return '(nothing)' + contents.sendInputEvent({ type: 'keyDown', keyCode: key }) + if ([...key].length === 1) contents.sendInputEvent({ type: 'char', keyCode: key }) + contents.sendInputEvent({ type: 'keyUp', keyCode: key }) + return contents.getURL() + }), + keys, + ) +} + +/** Starts a slow load in a tab: the page commits only when the test releases it. */ +async function slowLoad(tabId: string, path: string): Promise { + await cmd(orbit.app, 'tabs.navigate', { tabId, url: fx.url(path) }) + await expect.poll(() => fx.hits(path)).toBe(1) +} + +/** Lets the slow page in and waits until its tab has finished loading. */ +async function finishLoad(tabId: string, path: string): Promise { + fx.release(path) + await expect.poll(async () => (await state(orbit.app)).tabs[tabId]?.loading).toBe(false) + await expect.poll(async () => (await state(orbit.app)).tabs[tabId]?.url).toBe(fx.url(path)) +} + +test('[KS-16] keys typed into the command bar while a slow page finishes loading all reach the bar', async () => { + const { app, shell } = orbit + const id = await openTab(app, fx.url('/page/Start')) + await slowLoad(id, '/slow/Late') + await shell.keyboard.press(`${MOD}+T`) + const input = (await viewPage(app, 'overlay')).getByTestId('command-input') + await expect(input).toBeFocused() + const before = await typeAnywhere(['a', 'b', 'c']) + await finishLoad(id, '/slow/Late') + const after = await typeAnywhere(['d', 'e', 'f']) + // Every key went to the command bar's view, none to the page that finished loading. + expect(new Set([...before, ...after]).size).toBe(1) + expect(after[0]).toContain('view=overlay') + await expect(input).toHaveValue('abcdef') +}) + +test('[KS-16] a page that finishes loading leaves a sidebar field being typed in alone', async () => { + const { app, shell } = orbit + const id = await openTab(app, fx.url('/page/Start')) + await slowLoad(id, '/slow/Renamed') + // Double-clicking the tab's row gives the sidebar the keyboard and opens the name field. + await run(app, 'tabs.focusSidebar') + await shell.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`).dblclick() + const field = shell.getByTestId('rename-field') + await expect(field).toBeFocused() + await typeAnywhere(['a', 'b']) + await finishLoad(id, '/slow/Renamed') + await typeAnywhere(['c', 'd']) + await expect(field).toBeVisible() + await expect(field).toHaveValue('abcd') +}) + +test('[KS-16] a tab in the background that loads again does not take the keyboard from the page in front', async () => { + const { app } = orbit + const back = await openTab(app, fx.url('/page/Back')) + await slowLoad(back, '/slow/BackAgain') + const front = await openTab(app, fx.url('/page/Front')) + await expect.poll(async () => (await state(app)).tabs[front]?.loading).toBe(false) + expect(await typeAnywhere(['a'])).toEqual([fx.url('/page/Front')]) + await finishLoad(back, '/slow/BackAgain') + expect(await typeAnywhere(['b', 'c'])).toEqual([fx.url('/page/Front'), fx.url('/page/Front')]) +}) From 755e235d93aed776b7d33881e69b8f29b0509365 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:59:52 +0000 Subject: [PATCH 02/11] feat(keys): one live keymap with remapping, several bindings and persistence (KS-01, KS-18) Every action's bindings now come from one keymap: the defaults in src/shared/actions.ts with the user's changes from settings.shortcuts on top. The key router, the macOS menu bar (rebuilt after a remap), the command bar's action rows and the tooltips read it live, so a remap reaches every hint without a restart. - keys.bind / keys.unbind / keys.reset / keys.resetAll: replace a binding or add one (up to 5 per action), take one off (the command bar and menus still run the action), reset to the defaults. A binding needs a modifier except F1-F12; keys the system keeps are refused; a key another action has is reported as a conflict until the caller asks to clear or swap it; fixed actions (Settings, the recent-tab switcher) keep theirs. - Bindings that type or edit text (Shift+letter, the caret keys) act only outside text fields, like Cmd+Left already did; a Ctrl+Alt press that typed an AltGr character acts only outside text fields; plain keys belong to an input method while it composes. - settings.shortcuts and settings.shortcutPriority are cleaned on load. - keys.record lets Settings -> Shortcuts record a binding in main. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 18 +- docs/feature-status.json | 2 + src/main/archive/index.ts | 8 +- src/main/commandbar/sources.ts | 6 +- src/main/features.ts | 3 + src/main/keys/bindings.ts | 187 ++++++++ src/main/keys/index.ts | 69 +++ src/main/keys/router.ts | 68 +++ src/main/persist.ts | 2 + src/main/windows.ts | 6 + src/renderer/src/keys/live.ts | 33 ++ src/renderer/src/library/LibraryButton.tsx | 10 +- src/renderer/src/mini/MiniApp.tsx | 5 +- src/renderer/src/overlay/PeekLayer.tsx | 3 + .../src/overlay/find/PageToolsApp.tsx | 5 + src/renderer/src/shell/ContentArea.tsx | 15 +- src/renderer/src/shell/chrome/Address.tsx | 3 + src/renderer/src/shell/chrome/NavButtons.tsx | 5 + .../src/shell/sidebar/SidebarItems.tsx | 5 +- src/renderer/src/shell/sidebar/SpaceBar.tsx | 4 +- src/renderer/src/shell/sidebar/TopBar.tsx | 3 + src/renderer/src/shortcuts.ts | 3 + src/shared/actions.ts | 270 +++++------ src/shared/commands/keys.ts | 60 +++ src/shared/keymap.ts | 428 ++++++++++++++++++ src/shared/rpc.ts | 4 +- src/shared/state.ts | 8 + tests/e2e/keys.spec.ts | 111 ++++- tests/unit/keymap-bindings.test.ts | 139 ++++++ tests/unit/keymap-remap.test.ts | 235 ++++++++++ 30 files changed, 1564 insertions(+), 154 deletions(-) create mode 100644 src/main/keys/bindings.ts create mode 100644 src/main/keys/index.ts create mode 100644 src/main/keys/router.ts create mode 100644 src/renderer/src/keys/live.ts create mode 100644 src/shared/commands/keys.ts create mode 100644 src/shared/keymap.ts create mode 100644 tests/unit/keymap-bindings.test.ts create mode 100644 tests/unit/keymap-remap.test.ts diff --git a/FEATURES.md b/FEATURES.md index fa1b5af9..3947961a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 201 built (72 partial) · 281 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 201 built (74 partial) · 285 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 131 | 159 | -| P1 | 221 | 61 | 111 | +| P0 | 174 | 131 | 161 | +| P1 | 221 | 61 | 113 | | P2 | 80 | 9 | 11 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -346,9 +346,9 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| KS-01 | One keymap, remapping and persistence | P0 | – | ✅ e2e | – | | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | – | – | – | | -| KS-03 | Platform mapping rules | P0 | – | – | – | | +| KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08 | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | – | ✅ unit | – | | +| KS-03 | Platform mapping rules | P0 | – | ✅ unit | – | | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | | KS-05 | Tabs and windows | P0 | – | – | – | | | KS-06 | Switching tabs | P0 | – | – | – | | @@ -361,9 +361,9 @@ Spec: `spec/shortcuts.md` | KS-13 | Developer tools | P1 | – | – | – | | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | | KS-15 | Standard app and window keys | P1 | – | – | – | | -| KS-16 | Text-editing keys keep their meaning | P0 | 🟡 partial | ✅ e2e | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do | +| KS-16 | Text-editing keys keep their meaning | P0 | 🟡 partial | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | – | – | – | | -| KS-18 | Shortcut hints everywhere | P1 | – | – | – | | +| KS-18 | Shortcut hints everywhere | P1 | 🟡 partial | ✅ e2e | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come | | KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e | – | command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes | | KS-20 | Shortcuts in special windows | P1 | – | – | – | | @@ -542,7 +542,7 @@ Spec: `spec/settings.md` | ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | | ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | -| ST-08 | Shortcuts section (remapping) | P1 | – | – | – | | +| ST-08 | Shortcuts section (remapping) | P1 | – | ✅ unit | – | | | ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | diff --git a/docs/feature-status.json b/docs/feature-status.json index 79bf8a39..59365038 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -137,6 +137,8 @@ "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, "KS-19": {"built": "partial", "note": "command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes"}, "KS-16": {"built": "partial", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do"}, + "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08"}, + "KS-18": {"built": "partial", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, "FL-14": {"built": "yes", "note": "theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade"}, "FL-16": {"built": "yes", "note": "motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window"}, diff --git a/src/main/archive/index.ts b/src/main/archive/index.ts index d565fad1..31ddb03a 100644 --- a/src/main/archive/index.ts +++ b/src/main/archive/index.ts @@ -385,8 +385,11 @@ export function registerArchive(ctx: MainContext): ArchiveApi { // ---- Recently Closed (AR-09) ---- const reopenAction = ACTIONS_BY_ID['tab.reopenClosed'] - const reopenShortcut = reopenAction ? shortcutsFor(reopenAction, process.platform)[0] : undefined - const reopenKey = reopenShortcut ? toElectronAccelerator(reopenShortcut) : undefined + // Read as the menu opens: the user may have remapped it (KS-01, KS-18). + const reopenKeyNow = () => { + const shortcut = reopenAction ? shortcutsFor(reopenAction, process.platform)[0] : undefined + return shortcut ? toElectronAccelerator(shortcut) : undefined + } // The app menu's Recently Closed submenu follows the list (macOS has the only app menu). watchRecentlyClosed(ctx, () => { if (process.platform === 'darwin') installMenu(ctx) @@ -395,6 +398,7 @@ export function registerArchive(ctx: MainContext): ArchiveApi { const windowId = windowOf(c) await refreshRecentlyClosed(ctx) const list = recentlyClosedItems(ctx, () => windowId) + const reopenKey = reopenKeyNow() const items: MenuItemConstructorOptions[] = [ ...list, { type: 'separator' }, diff --git a/src/main/commandbar/sources.ts b/src/main/commandbar/sources.ts index cd39c489..46619cf7 100644 --- a/src/main/commandbar/sources.ts +++ b/src/main/commandbar/sources.ts @@ -5,6 +5,7 @@ */ import { ACTIONS, formatAccelerator, shortcutsFor } from '../../shared/actions' import { isFolderId, isSplitId, isTabId } from '../../shared/ids' +import { keymapVersion } from '../../shared/keymap' import type { ArchivedTab, FolderId, ItemId, OrbitState, ProfileId, SpaceId, Tab, TabId } from '../../shared/state' import { splitLeaves } from '../../shared/tree' import { type ArchiveSource, archiveSource } from '../archive/inbox' @@ -59,6 +60,8 @@ export class SourceCache { private spaceDocs: SpaceDoc[] = [] private names: Record = {} private actionDocs: ActionDoc[] | undefined + /** The keymap the action docs' shortcuts were read from: a remap builds them again (KS-18). */ + private actionDocsKeymap = -1 /** Every tab in every Space (pinned, Favorites, Today, folders, splits). */ tabs(state: OrbitState): TabDoc[] { @@ -148,7 +151,8 @@ export class SourceCache { /** Visible actions that have a handler, with their shortcut for this platform (CB-13). */ actions(has: (id: string) => boolean, platform: string): ActionDoc[] { - if (this.actionDocs) return this.actionDocs + if (this.actionDocs && this.actionDocsKeymap === keymapVersion()) return this.actionDocs + this.actionDocsKeymap = keymapVersion() this.actionDocs = ACTIONS.filter((a) => !a.hidden && !NOT_IN_BAR.has(a.id) && has(a.id)).map((a) => { const accel = shortcutsFor(a, platform)[0] const titleLower = a.title.toLowerCase() diff --git a/src/main/features.ts b/src/main/features.ts index fcfb1441..14149ee5 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -6,6 +6,7 @@ import { registerDownloads } from './downloads' import { registerExtensions } from './extensions' import { registerHistory } from './history' import { registerImport } from './import' +import { registerKeys } from './keys' import { registerMedia } from './media' import { registerMiniWindow } from './miniwindow' import { registerNav } from './nav' @@ -19,6 +20,8 @@ import { registerUpdates } from './updates' /** Every feature module registers its commands, actions and hooks here, in dependency order. */ export function registerFeatures(ctx: MainContext): void { + // First: the user's key bindings, so whatever reads a shortcut as it registers reads theirs (KS-01). + registerKeys(ctx) registerCore(ctx) const { ops } = registerTabs(ctx) registerNav(ctx, ops) diff --git a/src/main/keys/bindings.ts b/src/main/keys/bindings.ts new file mode 100644 index 00000000..8e5d2317 --- /dev/null +++ b/src/main/keys/bindings.ts @@ -0,0 +1,187 @@ +import { + ACTIONS, + ACTIONS_BY_ID, + type ActionDef, + buildKeymap, + defaultPriorityFor, + defaultShortcutsFor, +} from '../../shared/actions' +import type { BindRequest, BindResult, ShortcutPriority } from '../../shared/commands/keys' +import { bindingProblem, type CustomBindings, canonicalAccelerator, formatAccelerator } from '../../shared/keymap' +import type { Settings } from '../../shared/state' + +/** + * The user's key bindings (KS-01) and priorities (KS-02) as `settings.shortcuts` and + * `settings.shortcutPriority` keep them. Pure: each change takes the stored map and returns the + * next one, which src/main/keys/index.ts saves. + */ + +/** Most bindings one action keeps (KS-01: aliases such as Back = ⌘[ and ⌘←). */ +export const MAX_BINDINGS = 5 + +type Bindings = Record + +const isRecord = (v: unknown): v is Record => typeof v === 'object' && v !== null && !Array.isArray(v) + +const canon = (list: readonly string[]): string[] => + list.map((a) => canonicalAccelerator(a)).filter((a): a is string => a !== undefined) + +const sameList = (a: readonly string[], b: readonly string[]) => a.length === b.length && a.every((x, i) => x === b[i]) + +const copy = (custom: CustomBindings | undefined): Bindings => + Object.fromEntries(Object.entries(custom ?? {}).map(([id, list]) => [id, [...list]])) + +/** Stores an action's list, or drops its entry when the list is its defaults again. */ +function settle(custom: Bindings, action: ActionDef, list: string[], platform: string): void { + if (sameList(list, canon(defaultShortcutsFor(action, platform)))) delete custom[action.id] + else custom[action.id] = list +} + +/** + * The stored bindings made safe to use (on load): actions that exist and can change, bindings + * Orbit can use (KS-01 validation), each binding on one action (the first in the action list + * keeps it; fixed actions' keys are never taken), at most MAX_BINDINGS each. Undefined when + * nothing differs from the defaults. + */ +export function normalizeShortcuts(value: unknown, platform: string): Bindings | undefined { + if (!isRecord(value)) return undefined + const out: Bindings = {} + const taken = new Set() + for (const action of ACTIONS) + if (action.fixed) for (const key of canon(defaultShortcutsFor(action, platform))) taken.add(key) + for (const action of ACTIONS) { + const raw = value[action.id] + if (action.fixed || !Array.isArray(raw)) continue + const list: string[] = [] + for (const accel of raw) { + if (typeof accel !== 'string' || bindingProblem(accel, platform) !== undefined) continue + const key = canonicalAccelerator(accel) as string + if (taken.has(key) || list.length >= MAX_BINDINGS) continue + taken.add(key) + list.push(key) + } + settle(out, action, list, platform) + } + return Object.keys(out).length > 0 ? out : undefined +} + +/** The stored priorities made safe to use: known actions, known values, only where they differ from the default. */ +export function normalizePriorities(value: unknown, platform: string): Record | undefined { + if (!isRecord(value)) return undefined + const out: Record = {} + for (const [id, priority] of Object.entries(value)) { + const action = ACTIONS_BY_ID[id] + if (!action || action.fixed || (priority !== 'websites' && priority !== 'orbit')) continue + if (priority !== defaultPriorityFor(action, platform)) out[id] = priority + } + return Object.keys(out).length > 0 ? out : undefined +} + +const fail = (custom: Bindings, problem: string): { custom: Bindings; result: BindResult } => ({ + custom, + result: { ok: false, problem }, +}) + +/** Gives an action a binding (ST-08): see BindRequest. The map is unchanged unless the result is ok. */ +export function bind( + stored: CustomBindings | undefined, + req: BindRequest, + platform: string, +): { custom: Bindings; result: BindResult } { + const custom = copy(stored) + const action = ACTIONS_BY_ID[req.actionId] + if (!action) return fail(custom, 'That action no longer exists.') + if (action.fixed) return fail(custom, `${action.title} keeps its shortcut.`) + const problem = bindingProblem(req.accel, platform) + if (problem) return fail(custom, problem) + const accel = canonicalAccelerator(req.accel) as string + const replace = req.replace === undefined ? undefined : canonicalAccelerator(req.replace) + const keymap = buildKeymap(custom, platform) + const mine = canon(keymap.lists.get(action.id) ?? []) + const owner = keymap.direct.get(accel) ?? keymap.outsideText.get(accel) + if (owner && owner.id !== action.id) { + if (owner.fixed) { + return fail(custom, `${formatAccelerator(accel, platform)} is kept for ${owner.title}.`) + } + if (!req.onConflict) { + return { custom, result: { ok: false, conflict: { actionId: owner.id, title: owner.title } } } + } + const theirs = canon(keymap.lists.get(owner.id) ?? []) + const at = theirs.indexOf(accel) + // Swap: the other action gets the binding this one gives up, where the taken one was. + if (req.onConflict === 'swap' && replace && mine.includes(replace) && !theirs.includes(replace)) { + theirs.splice(at, 1, replace) + } else { + theirs.splice(at, 1) + } + settle(custom, owner, theirs, platform) + } + let list = replace && mine.includes(replace) ? mine.map((a) => (a === replace ? accel : a)) : [...mine, accel] + list = [...new Set(list)] + if (list.length > MAX_BINDINGS) return fail(copy(stored), `An action can have up to ${MAX_BINDINGS} shortcuts.`) + settle(custom, action, list, platform) + return { custom, result: { ok: true } } +} + +/** Takes one binding off an action. */ +export function unbind( + stored: CustomBindings | undefined, + actionId: string, + accel: string, + platform: string, +): Bindings { + const custom = copy(stored) + const action = ACTIONS_BY_ID[actionId] + const key = canonicalAccelerator(accel) + if (!action || action.fixed || !key) return custom + const list = canon(buildKeymap(custom, platform).lists.get(actionId) ?? []) + if (list.includes(key)) + settle( + custom, + action, + list.filter((a) => a !== key), + platform, + ) + return custom +} + +/** Puts an action's defaults back, taking each default binding back from any action given it since. */ +export function reset(stored: CustomBindings | undefined, actionId: string, platform: string): Bindings { + const custom = copy(stored) + const action = ACTIONS_BY_ID[actionId] + if (!action || action.fixed) return custom + delete custom[actionId] + const defaults = new Set(canon(defaultShortcutsFor(action, platform))) + for (const [id, list] of Object.entries(custom)) { + const other = ACTIONS_BY_ID[id] + if (!other) continue + const kept = canon(list).filter((a) => !defaults.has(a)) + if (kept.length !== list.length) settle(custom, other, kept, platform) + } + return custom +} + +/** Sets who gets an action's keys first (KS-02); the default needs no entry. */ +export function setPriority( + stored: Readonly> | undefined, + actionId: string, + priority: ShortcutPriority, + platform: string, +): Record { + const out = { ...(stored ?? {}) } + const action = ACTIONS_BY_ID[actionId] + if (!action || action.fixed || (priority !== 'websites' && priority !== 'orbit')) return out + if (priority === defaultPriorityFor(action, platform)) delete out[actionId] + else out[actionId] = priority + return out +} + +/** The keys lane's fields of loaded settings (KS-01, KS-02), made safe. Called from normalizeState. */ +export function normalizeKeySettings(settings: Settings, platform: string): void { + const shortcuts = normalizeShortcuts(settings.shortcuts, platform) + if (shortcuts) settings.shortcuts = shortcuts + else delete settings.shortcuts + const priorities = normalizePriorities(settings.shortcutPriority, platform) + if (priorities) settings.shortcutPriority = priorities + else delete settings.shortcutPriority +} diff --git a/src/main/keys/index.ts b/src/main/keys/index.ts new file mode 100644 index 00000000..0bd7f08e --- /dev/null +++ b/src/main/keys/index.ts @@ -0,0 +1,69 @@ +import type { BindResult, ShortcutPriority } from '../../shared/commands/keys' +import { onKeymapChange, setCustomBindings } from '../../shared/keymap' +import type { MainContext } from '../context' +import { installMenu } from '../menu' +import { bind, reset, setPriority, unbind } from './bindings' +import { keyRouter } from './router' + +/** + * The keymap in main (KS-01): the user's bindings follow `settings.shortcuts`, the menu bar is + * built again after a remap, and the keys.* commands change bindings and priorities for Settings → + * Shortcuts (ST-08). Registered before every other feature, so whatever reads a shortcut while it + * starts reads the user's. + */ +export function registerKeys(ctx: MainContext): void { + const { store, commands } = ctx + const platform = process.platform + setCustomBindings(store.state.settings.shortcuts) + store.subscribe((next, prev) => { + if (next.settings.shortcuts !== prev.settings.shortcuts) setCustomBindings(next.settings.shortcuts) + }) + // The macOS menu bar shows each item's binding (KS-18). + onKeymapChange(() => installMenu(ctx)) + + const saveBindings = (custom: Record) => + store.update((d) => { + if (Object.keys(custom).length > 0) d.settings.shortcuts = custom + else delete d.settings.shortcuts + }) + const savePriorities = (chosen: Record) => + store.update((d) => { + if (Object.keys(chosen).length > 0) d.settings.shortcutPriority = chosen + else delete d.settings.shortcutPriority + }) + const text = (v: unknown): v is string => typeof v === 'string' && v.length > 0 && v.length <= 128 + + commands.register('keys.bind', (args): BindResult => { + if (!text(args?.actionId) || !text(args.accel)) return { ok: false, problem: 'Nothing to bind.' } + const req = { + actionId: args.actionId, + accel: args.accel, + ...(text(args.replace) ? { replace: args.replace } : {}), + ...(args.onConflict === 'clear' || args.onConflict === 'swap' ? { onConflict: args.onConflict } : {}), + } + const { custom, result } = bind(store.state.settings.shortcuts, req, platform) + if (result.ok) saveBindings(custom) + return result + }) + commands.register('keys.unbind', ({ actionId, accel }) => { + if (!text(actionId) || !text(accel)) return + saveBindings(unbind(store.state.settings.shortcuts, actionId, accel, platform)) + }) + commands.register('keys.reset', ({ actionId }) => { + if (!text(actionId)) return + saveBindings(reset(store.state.settings.shortcuts, actionId, platform)) + const chosen = { ...(store.state.settings.shortcutPriority ?? {}) } + delete chosen[actionId] + savePriorities(chosen) + }) + commands.register('keys.resetAll', () => { + saveBindings({}) + savePriorities({}) + }) + commands.register('keys.setPriority', ({ actionId, priority }) => { + if (!text(actionId)) return + savePriorities(setPriority(store.state.settings.shortcutPriority, actionId, priority, platform)) + }) + keyRouter.init(ctx) + commands.register('keys.record', ({ on }, c) => keyRouter.setRecording(on ? c.windowId : undefined)) +} diff --git a/src/main/keys/router.ts b/src/main/keys/router.ts new file mode 100644 index 00000000..8d674f97 --- /dev/null +++ b/src/main/keys/router.ts @@ -0,0 +1,68 @@ +import { type WebContents, webContents } from 'electron' +import type { RecordedKey } from '../../shared/commands/keys' +import { acceleratorFromInput, modifiersOf } from '../../shared/keymap' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' + +/** + * The keys lane's part of the key router (src/main/windows.ts `attachShortcuts` calls it): a + * window where Settings → Shortcuts is recording a new binding gets its key presses reported + * instead of acted on (ST-08). + */ +export class KeyRouter { + private ctx: MainContext | undefined + /** The window whose key presses are being recorded (the Settings window), if any. */ + private recordingIn: WindowId | undefined + + init(ctx: MainContext): void { + this.ctx = ctx + } + + /** Starts (with the window) or stops recording. Closing the window stops it too. */ + setRecording(windowId: WindowId | undefined): void { + this.recordingIn = windowId + if (!windowId || !this.ctx) return + for (const contents of webContents.getAllWebContents()) { + if (this.ctx.windows.windowIdFor(contents) !== windowId) continue + contents.once('destroyed', () => { + if (this.recordingIn === windowId) this.recordingIn = undefined + }) + } + } + + /** + * While the window of `contents` is recording, reports a key press to the page as + * `keys.recorded` and returns true: the caller then keeps the key from the page, the menus and + * every shortcut. + */ + record(contents: WebContents, windowOf: () => WindowId | undefined, input: Electron.Input): boolean { + if (!this.recordingIn || windowOf() !== this.recordingIn) return false + if (input.type !== 'keyDown' && input.type !== 'keyUp') return true + const press = { + key: input.key, + code: input.code, + meta: input.meta, + control: input.control, + alt: input.alt, + shift: input.shift, + } + // A modifier's own release clears it from what is held. + const held = + input.type === 'keyUp' + ? { + meta: input.meta && input.key !== 'Meta', + control: input.control && input.key !== 'Control', + alt: input.alt && input.key !== 'Alt', + shift: input.shift && input.key !== 'Shift', + } + : press + const recorded: RecordedKey = { modifiers: modifiersOf(held) } + const accel = input.type === 'keyDown' ? acceleratorFromInput(press) : undefined + if (accel) recorded.accel = accel + if (!contents.isDestroyed()) contents.send('orbit:keys.recorded', recorded) + return true + } +} + +/** The one key router extension for the app. */ +export const keyRouter = new KeyRouter() diff --git a/src/main/persist.ts b/src/main/persist.ts index 8eb0fc79..78b406b5 100644 --- a/src/main/persist.ts +++ b/src/main/persist.ts @@ -19,6 +19,7 @@ import type { ItemId, OrbitState, ProfileId, SpaceId, SplitLayout, TabId } from import { STATE_SCHEMA } from '../shared/state' import { normalizeSpaceTheme } from '../shared/theme' import { fallbackTitle } from '../shared/url' +import { normalizeKeySettings } from './keys/bindings' import { normalizeSettingsPrefs } from './settings/normalize' import type { Store } from './store' @@ -159,6 +160,7 @@ export function normalizeState(input: Partial): OrbitState { if (d.state === 'progressing' || d.state === 'paused') d.state = 'interrupted' } normalizeSettingsPrefs(state) + normalizeKeySettings(state.settings, process.platform) return state } diff --git a/src/main/windows.ts b/src/main/windows.ts index e2980f32..1375c095 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -18,6 +18,7 @@ import { paletteFor } from '../shared/theme' import { splitLeaves, splitOfTab } from '../shared/tree' import type { MainContext } from './context' import { KEEP_KEYBOARD } from './keys/focus' +import { keyRouter } from './keys/router' import { focusIsEditable } from './nav/focus' import { fastSwitchPanes } from './split/layout' @@ -1055,6 +1056,11 @@ export class WindowManager { /** Global keyboard shortcuts: every Orbit-owned web contents routes key presses through the action map. */ attachShortcuts(contents: WebContents): void { contents.on('before-input-event', (event, input) => { + // Settings → Shortcuts is recording a new binding here (keys lane, ST-08): the key runs nothing. + if (keyRouter.record(contents, () => this.windowIdFor(contents), input)) { + event.preventDefault() + return + } if (input.type !== 'keyDown') return const action = actionForInput(input, process.platform) if (!action || !this.ctx.actions.has(action.id)) { diff --git a/src/renderer/src/keys/live.ts b/src/renderer/src/keys/live.ts new file mode 100644 index 00000000..eb32c454 --- /dev/null +++ b/src/renderer/src/keys/live.ts @@ -0,0 +1,33 @@ +import { useSyncExternalStore } from 'react' +import { ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../../shared/actions' +import { keymapVersion, onKeymapChange, setCustomBindings } from '../../../shared/keymap' +import { useMirror } from '../store' + +/** + * The keymap in an Orbit renderer (KS-01, KS-18): the user's bindings follow the state mirror's + * `settings.shortcuts`, and hints read them through `shortcutLabel` while `useKeymap` re-renders + * a component after a remap. + */ +export function followKeymap(): void { + const apply = () => setCustomBindings(useMirror.getState().state?.settings.shortcuts) + apply() + useMirror.subscribe(apply) +} + +/** Re-renders the component when the user changes a binding; returns the keymap's version. */ +export function useKeymap(): number { + return useSyncExternalStore(onKeymapChange, keymapVersion) +} + +/** The action's first shortcut as this platform shows it ("⇧⌘T", "Ctrl+Shift+T"), or '' when it has none. */ +export function shortcutLabel(actionId: string): string { + const action = ACTIONS_BY_ID[actionId] + const accel = action ? shortcutsFor(action, window.orbit.platform)[0] : undefined + return accel ? formatAccelerator(accel, window.orbit.platform) : '' +} + +/** `shortcutLabel` that follows remaps: the component re-renders when the binding changes. */ +export function useShortcutLabel(actionId: string): string { + useKeymap() + return shortcutLabel(actionId) +} diff --git a/src/renderer/src/library/LibraryButton.tsx b/src/renderer/src/library/LibraryButton.tsx index 802d463a..7fc0f92c 100644 --- a/src/renderer/src/library/LibraryButton.tsx +++ b/src/renderer/src/library/LibraryButton.tsx @@ -1,19 +1,17 @@ import { LibraryBig } from 'lucide-react' import { useEffect, useState } from 'react' -import { ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../../shared/actions' import { invoke } from '../api' +import { useShortcutLabel } from '../keys/live' import './library-button.css' -const action = ACTIONS_BY_ID['app.library'] -const accel = action ? shortcutsFor(action, window.orbit.platform)[0] : undefined -const TITLE = accel ? `Library (${formatAccelerator(accel, window.orbit.platform)})` : 'Library' - /** * The Library button at the left of the sidebar's bottom bar (SB-09, AR-02): opens the Library, or * closes it when it is open (shown pressed). A hover gets its view ready, so the first open is quick. */ export function LibraryButton() { const [open, setOpen] = useState(false) + // The tooltip follows a remapped shortcut (KS-18). + const shortcut = useShortcutLabel('app.library') useEffect(() => { let heard = false const off = window.orbit.on('library.state', (payload) => { @@ -31,7 +29,7 @@ export function LibraryButton() { className={`icon-button spacebar__library${open ? ' is-open' : ''}`} aria-label="Library" aria-pressed={open} - title={TITLE} + title={shortcut ? `Library (${shortcut})` : 'Library'} data-testid="library-button" onPointerEnter={() => void invoke('library.prepare', {})} onClick={() => void invoke('library.toggle', {})} diff --git a/src/renderer/src/mini/MiniApp.tsx b/src/renderer/src/mini/MiniApp.tsx index c571e80c..3ec37aa0 100644 --- a/src/renderer/src/mini/MiniApp.tsx +++ b/src/renderer/src/mini/MiniApp.tsx @@ -4,6 +4,7 @@ import type { Tab } from '../../../shared/state' import { NEUTRAL_THEME, paletteFor } from '../../../shared/theme' import { displayHost } from '../../../shared/url' import { invoke, isMac, perfMark } from '../api' +import { useShortcutLabel } from '../keys/live' import { activeTabId } from '../selectors' import { useOrbit, useWindow } from '../store' import { paletteVars, useDark } from '../theme' @@ -20,6 +21,8 @@ const CARD_RADIUS = 10 */ export function MiniApp() { const win = useWindow() + // "Open in…" shows the shortcut that moves the page into a Space, as the user has it (MW-05, KS-18). + const moveShortcut = useShortcutLabel('peek.expand') const live = useOrbit((s) => { const id = win ? activeTabId(s, win.id) : null return id ? s.tabs[id] : undefined @@ -93,7 +96,7 @@ export function MiniApp() { type="button" className="mini__pill mini__open-in" data-testid="mini-open-in" - title={`Move this page into a Space (${isMac ? '⌘O' : 'Ctrl+O'})`} + title={moveShortcut ? `Move this page into a Space (${moveShortcut})` : 'Move this page into a Space'} onClick={(e) => { const r = e.currentTarget.getBoundingClientRect() void invoke('mini.menu', { menu: 'open-in', x: r.left, y: r.bottom + 4 }) diff --git a/src/renderer/src/overlay/PeekLayer.tsx b/src/renderer/src/overlay/PeekLayer.tsx index 68112b1b..365df8b8 100644 --- a/src/renderer/src/overlay/PeekLayer.tsx +++ b/src/renderer/src/overlay/PeekLayer.tsx @@ -23,6 +23,7 @@ import { } from '../../../shared/peek-geometry' import type { OverlayState } from '../../../shared/state' import { invoke, perfMark } from '../api' +import { useKeymap } from '../keys/live' import './peek.css' type PeekOverlay = Extract @@ -403,6 +404,8 @@ class PeekAnimator { * itself is a native view main keeps exactly on the card. */ export function PeekLayer({ overlay }: { overlay: PeekOverlay }) { + // The buttons' tooltips follow remapped shortcuts (KS-18). + useKeymap() const backdropRef = useRef(null) const cardRef = useRef(null) const controlsRef = useRef(null) diff --git a/src/renderer/src/overlay/find/PageToolsApp.tsx b/src/renderer/src/overlay/find/PageToolsApp.tsx index 2513e3a1..86b3f735 100644 --- a/src/renderer/src/overlay/find/PageToolsApp.tsx +++ b/src/renderer/src/overlay/find/PageToolsApp.tsx @@ -2,6 +2,7 @@ import { ChevronDown, ChevronUp, X } from 'lucide-react' import { type CSSProperties, useEffect, useRef, useState } from 'react' import type { FindState, ZoomIndicator } from '../../../../shared/nav' import { invoke, runAction } from '../../api' +import { useKeymap } from '../../keys/live' import { withShortcut } from '../../shell/chrome/shortcut-label' import { useWindow } from '../../store' import { paletteVars, usePalette } from '../../theme' @@ -55,6 +56,8 @@ export function PageToolsApp() { } function FindBar({ state }: { state: FindState }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() const input = useRef(null) const composing = useRef(false) const [empty, setEmpty] = useState(state.query === '') @@ -176,6 +179,8 @@ function FindBar({ state }: { state: FindState }) { } function ZoomPill({ zoom }: { zoom: ZoomIndicator }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() return (
-

- Press {shortcut} to open a tab -

+ {shortcut ? ( +

+ Press {shortcut} to open a tab +

+ ) : ( +

Open a tab from the sidebar

+ )}
) diff --git a/src/renderer/src/shell/chrome/Address.tsx b/src/renderer/src/shell/chrome/Address.tsx index 8383f7d6..83e37d51 100644 --- a/src/renderer/src/shell/chrome/Address.tsx +++ b/src/renderer/src/shell/chrome/Address.tsx @@ -4,6 +4,7 @@ import { connectionOf, peekShown, shownTabId } from '../../../../shared/nav' import { SITE_PANEL } from '../../../../shared/site' import { displayHost } from '../../../../shared/url' import { invoke, runAction } from '../../api' +import { useKeymap } from '../../keys/live' import { useOrbit } from '../../store' import { withShortcut } from './shortcut-label' @@ -18,6 +19,8 @@ const COPIED_MS = 1200 * to open pop-ups without a click, a button says so and offers them (NV-09). */ export function Address({ windowId }: { windowId: string }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() // Only the URL: the display re-renders on navigations, not on title, favicon or progress changes. // While a Peek shows, it is the Peek's page (PK-11). A navigation Orbit started shows where it is // going before it commits, and after the user stopped it (NV-04). diff --git a/src/renderer/src/shell/chrome/NavButtons.tsx b/src/renderer/src/shell/chrome/NavButtons.tsx index cbea23b3..9ab16513 100644 --- a/src/renderer/src/shell/chrome/NavButtons.tsx +++ b/src/renderer/src/shell/chrome/NavButtons.tsx @@ -4,6 +4,7 @@ import type { NavDirection } from '../../../../shared/commands/nav' import { shownTabId } from '../../../../shared/nav' import { sectionOf } from '../../../../shared/tree' import { invoke, isMac } from '../../api' +import { useKeymap } from '../../keys/live' import { useOrbitShallow } from '../../store' import { playPress } from './press' import { rasterizeSvgIcons } from './raster' @@ -56,6 +57,8 @@ export function NavButtons({ windowId }: { windowId: string }) { * long-press or right-click shows the tab's history in that direction (NV-01). */ function HistoryButton({ direction, enabled }: { direction: NavDirection; enabled: boolean }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() const ref = useRef(null) const hold = useRef<{ timer: number; opened: boolean } | null>(null) const label = direction === 'back' ? 'Back' : 'Forward' @@ -143,6 +146,8 @@ function HistoryButton({ direction, enabled }: { direction: NavDirection; enable * ⌘-click or middle-click duplicates the tab (SB-03, NV-03, NV-04). */ function ReloadButton({ enabled, loading }: { enabled: boolean; loading: boolean }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() const ref = useRef(null) const duplicate = (foreground: boolean) => { playPress(ref.current, 'reload') diff --git a/src/renderer/src/shell/sidebar/SidebarItems.tsx b/src/renderer/src/shell/sidebar/SidebarItems.tsx index aa7deffa..cda630bd 100644 --- a/src/renderer/src/shell/sidebar/SidebarItems.tsx +++ b/src/renderer/src/shell/sidebar/SidebarItems.tsx @@ -17,6 +17,7 @@ import type { Space, SpaceId } from '../../../../shared/state' import { containerKey, locate, sectionOf, splitOfTab } from '../../../../shared/tree' import { engineForSpace, parseInput } from '../../../../shared/url' import { invoke, isMac, runAction } from '../../api' +import { useShortcutLabel } from '../../keys/live' import { activeTabId } from '../../selectors' import { getOrbitState, useOrbit } from '../../store' import { DragGhost } from './DragGhost' @@ -224,6 +225,8 @@ const PinnedSection = memo(function PinnedSection({ space }: { space: Space }) { function TodayBlock({ space }: { space: Space }) { const y = useRowShift('divider') const hasToday = space.today.length > 0 + // The button's tooltip follows a remapped shortcut (KS-18). + const clearShortcut = useShortcutLabel('tabs.clearToday') return ( void invoke('tabs.clearToday', { spaceId: space.id })} > diff --git a/src/renderer/src/shell/sidebar/SpaceBar.tsx b/src/renderer/src/shell/sidebar/SpaceBar.tsx index 8ed071ae..18da5677 100644 --- a/src/renderer/src/shell/sidebar/SpaceBar.tsx +++ b/src/renderer/src/shell/sidebar/SpaceBar.tsx @@ -13,6 +13,7 @@ import { DEFAULT_PROFILE_ID } from '../../../../shared/defaults' import type { SpaceId, WindowState } from '../../../../shared/state' import { primaryColor } from '../../../../shared/theme' import { invoke } from '../../api' +import { useKeymap } from '../../keys/live' import { LibraryButton } from '../../library/LibraryButton' import { useOrbit } from '../../store' import { switcher } from '../spaces/switcher' @@ -208,8 +209,9 @@ const SpaceTarget = memo(function SpaceTarget(props: { const id = s.spaces[spaceId]?.profileId return id && id !== DEFAULT_PROFILE_ID ? s.profiles[id]?.name : undefined }) + // The shortcut comes from the keymap, so it follows remaps (AP-16, KS-18; review F14). + useKeymap() if (name === undefined) return null - // The shortcut comes from the action table, so it follows remaps (AP-16; review F14). const action = index < 9 ? ACTIONS_BY_ID[`space.goto.${index + 1}`] : undefined const accel = action ? shortcutsFor(action, window.orbit.platform)[0] : undefined const shortcut = accel ? formatAccelerator(accel, window.orbit.platform) : '' diff --git a/src/renderer/src/shell/sidebar/TopBar.tsx b/src/renderer/src/shell/sidebar/TopBar.tsx index d779fe88..6cd5fa47 100644 --- a/src/renderer/src/shell/sidebar/TopBar.tsx +++ b/src/renderer/src/shell/sidebar/TopBar.tsx @@ -1,6 +1,7 @@ import { PanelLeft } from 'lucide-react' import type { WindowState } from '../../../../shared/state' import { isMac, runAction } from '../../api' +import { useKeymap } from '../../keys/live' import { Address } from '../chrome/Address' import { NavButtons } from '../chrome/NavButtons' import { PermissionPrompt, useActivePrompt } from '../chrome/PermissionPrompt' @@ -15,6 +16,8 @@ import '../chrome/chrome.css' * A site's permission prompt hangs under the address display (NV-15). */ export function TopBar({ win, floating = false }: { win: WindowState; floating?: boolean }) { + // The tooltips follow remapped shortcuts (KS-18). + useKeymap() const prompt = useActivePrompt(win.id) return (
diff --git a/src/renderer/src/shortcuts.ts b/src/renderer/src/shortcuts.ts index b7d4e328..0cf2e5e1 100644 --- a/src/renderer/src/shortcuts.ts +++ b/src/renderer/src/shortcuts.ts @@ -1,4 +1,5 @@ import { actionForInput } from '../../shared/actions' +import { followKeymap } from './keys/live' /** * Fallback shortcut router for Orbit's own UI pages. Real key presses are @@ -7,6 +8,8 @@ import { actionForInput } from '../../shared/actions' * input from automation) is mapped through the same keymap. */ export function installShortcutFallback(): void { + // This page's hints and this fallback use the user's bindings (KS-01). + followKeymap() window.addEventListener( 'keydown', (e) => { diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 47fbd3bf..39a37132 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -1,17 +1,39 @@ /** * User-facing actions: things a person can trigger from a keyboard shortcut, * the app menu or the command bar. Handlers live in the main process - * (src/main/actions.ts registry); each feature registers its own. + * (src/main/registry.ts ActionRegistry); each feature registers its own. * - * Shortcuts use accelerator strings: modifiers Cmd, Ctrl, Alt, Shift joined by - * "+" with a key name (A-Z, 0-9, [ ] , . / ; ' ` - = \, Left Right Up Down, - * Tab Enter Escape Backspace Delete Space F1-F12 Home End PageUp PageDown). - * `mac` applies on macOS and `other` on Linux/Windows. + * This list is the one keymap (KS-01): each action's stable id, its title, and its default + * shortcuts, `mac` on macOS and `other` on Linux/Windows (accelerator strings, see + * src/shared/keymap.ts). The key router, the menus, tooltips, command-bar rows, the shortcut + * sheet and Settings → Shortcuts all read an action's shortcuts through `shortcutsFor`, which + * gives the user's own bindings where they changed them, so a remap reaches every one at once. * - * The final shortcut set follows spec/keyboard-shortcuts.md (KS-* IDs). + * The default set follows the KS-* tables of the shortcuts spec. */ +import type { ShortcutPriority } from './commands/keys' import { SETTINGS_ACTIONS, SETTINGS_LANE_ACTIONS } from './commands/settings' +import { + acceleratorFromInput, + type CustomBindings, + canonicalAccelerator, + customBindings, + editsText, + type KeyInput, + keymapVersion, + typedWithAltGr, +} from './keymap' + +export { + formatAccelerator, + type KeyInput, + keyName, + matchesAccelerator, + type ParsedAccelerator, + parseAccelerator, + toElectronAccelerator, +} from './keymap' export interface ActionDef { id: string @@ -30,6 +52,19 @@ export interface ActionDef { */ macOutsideText?: string[] otherOutsideText?: string[] + /** + * The shortcuts can't be changed (KS-01 "fixed": OS conventions such as ⌘, for Settings, and keys + * that belong to an overlay such as the recent-tab switcher's). Settings → Shortcuts shows them + * without a way to record new ones. + */ + fixed?: boolean + /** + * KS-02 "prefer websites": when a page also handles one of the action's keys, the page gets the + * first press, and pressing again within about a second runs the action. `true` on every + * platform, 'other' on Windows and Linux only. Otherwise the action runs and the page never sees + * the key ("prefer Orbit", the default). Users choose per action in Settings → Shortcuts. + */ + preferWebsites?: boolean | 'other' } const numbered = ( @@ -80,6 +115,7 @@ export const ACTIONS: ActionDef[] = [ mac: ['Ctrl+Tab'], other: ['Ctrl+Tab'], hidden: true, + fixed: true, }, { id: 'tab.recentBack', @@ -88,6 +124,7 @@ export const ACTIONS: ActionDef[] = [ mac: ['Ctrl+Shift+Tab', 'Ctrl+`'], other: ['Ctrl+Shift+Tab'], hidden: true, + fixed: true, }, { id: 'tab.togglePin', title: 'Pin / Unpin Tab', category: 'Tabs', mac: ['Cmd+D'], other: ['Ctrl+D'] }, { id: 'tab.duplicate', title: 'Duplicate Tab', category: 'Tabs' }, @@ -105,6 +142,8 @@ export const ACTIONS: ActionDef[] = [ mac: ['Cmd+Shift+C'], other: ['Ctrl+Shift+C'], keywords: ['link', 'share'], + // NV-17: on Windows and Linux the page gets Ctrl+Shift+C first (KS-02). + preferWebsites: 'other', }, { id: 'tab.copyUrlMarkdown', @@ -249,7 +288,15 @@ export const ACTIONS: ActionDef[] = [ }, // Sidebar & window - { id: 'sidebar.toggle', title: 'Toggle Sidebar', category: 'Sidebar', mac: ['Cmd+S'], other: ['Ctrl+S'] }, + { + id: 'sidebar.toggle', + title: 'Toggle Sidebar', + category: 'Sidebar', + mac: ['Cmd+S'], + other: ['Ctrl+S'], + // SB-11: a web editor's ⌘S / Ctrl+S gets the first press (KS-02). + preferWebsites: true, + }, { id: 'window.new', title: 'New Window', category: 'Window', mac: ['Cmd+N'], other: ['Ctrl+N'] }, { id: 'window.newMini', @@ -282,6 +329,8 @@ export const ACTIONS: ActionDef[] = [ mac: ['Cmd+,'], other: ['Ctrl+,'], keywords: ['preferences'], + // An OS convention (KS-12). + fixed: true, }, { id: 'app.archive', @@ -416,119 +465,109 @@ export const ACTIONS: ActionDef[] = [ export const ACTIONS_BY_ID: Record = Object.fromEntries(ACTIONS.map((a) => [a.id, a])) -export function shortcutsFor(action: ActionDef, platform: string): string[] { - return (platform === 'darwin' ? action.mac : action.other) ?? [] +/** The action's shortcuts as Orbit ships them (KS-03): `mac` on macOS, `other` elsewhere. */ +export function defaultShortcutsFor(action: ActionDef, platform: string): string[] { + const mac = platform === 'darwin' + return [ + ...((mac ? action.mac : action.other) ?? []), + ...((mac ? action.macOutsideText : action.otherOutsideText) ?? []), + ] } -// ---- Accelerator matching ---- - -export interface KeyInput { - key: string - code: string - meta: boolean - control: boolean - alt: boolean - shift: boolean +/** Who gets the action's keys first by default when a page also handles them (KS-02). */ +export function defaultPriorityFor(action: ActionDef, platform: string): ShortcutPriority { + const prefer = action.preferWebsites + return prefer === true || (prefer === 'other' && platform !== 'darwin') ? 'websites' : 'orbit' } -const CODE_NAMES: Record = { - BracketLeft: '[', - BracketRight: ']', - Comma: ',', - Period: '.', - Slash: '/', - Backslash: '\\', - Semicolon: ';', - Quote: "'", - Backquote: '`', - Minus: '-', - Equal: '=', - ArrowLeft: 'Left', - ArrowRight: 'Right', - ArrowUp: 'Up', - ArrowDown: 'Down', - NumpadAdd: '=', - NumpadSubtract: '-', - NumpadEnter: 'Enter', +/** Who gets the action's keys first: the user's choice (`settings.shortcutPriority`), else the default. */ +export function priorityFor( + action: ActionDef, + platform: string, + chosen?: Readonly>, +): ShortcutPriority { + return (!action.fixed && chosen?.[action.id]) || defaultPriorityFor(action, platform) } -/** Shifted ASCII symbols (US positions) → the unshifted key they share, so "Cmd+Shift+=" matches "+". */ -const SHIFTED_SYMBOLS: Record = { - '+': '=', - _: '-', - '{': '[', - '}': ']', - '<': ',', - '>': '.', - '?': '/', - ':': ';', - '"': "'", - '~': '`', - '|': '\\', +/** A keymap: which action each binding runs, and each action's bindings (KS-01). */ +export interface Keymap { + /** Each action's bindings, in their written order. */ + lists: Map + /** Binding (canonical) → action, for keys that never edit text. */ + direct: Map + /** Binding (canonical) → action, for keys that edit text in a field (KS-16): they act only outside one. */ + outsideText: Map } -const ASCII_SYMBOL = /^[!-/:-@[-`{-~]$/ + +/** Whether the user's list for an action applies: fixed actions keep their defaults. */ +const ownsList = (action: ActionDef, custom: CustomBindings) => !action.fixed && custom[action.id] !== undefined /** - * Key name for an input, matched against accelerator keys. Like Chrome: - * - letters follow the keyboard layout (`key`), so AZERTY's Cmd+Z is undo, not the physical W key; - * - digits use the physical key (`code`), so AZERTY's unshifted digit row still selects tabs; - * - ASCII symbols follow the layout too (Dvorak's "." sits on the physical E key); - * - anything else (macOS Option combos, Cyrillic and other non-Latin layouts, dead keys) falls - * back to the physical key, so Cmd+Option+V or Ctrl+T on a Russian layout still work. + * The keymap for a platform with the user's bindings `custom`: theirs where they changed an + * action, the defaults elsewhere. A binding belongs to one action: fixed actions keep theirs first, + * then the user's own bindings win over a default that came later (the default is left off its + * action). */ -export function keyName(input: Pick): string { - const { code, key } = input - if (/^[a-z]$/i.test(key)) return key.toUpperCase() - if (code.startsWith('Digit') && code.length === 6) return code.slice(5) - if (code.startsWith('Numpad') && code.length === 7 && /\d/.test(code[6] ?? '')) return code.slice(6) - if (ASCII_SYMBOL.test(key)) return SHIFTED_SYMBOLS[key] ?? key - if (code.startsWith('Key') && code.length === 4) return code.slice(3) - if (CODE_NAMES[code]) return CODE_NAMES[code] as string - if (key === ' ') return 'Space' - return key.length === 1 ? key.toUpperCase() : key +export function buildKeymap(custom: CustomBindings, platform: string): Keymap { + const owner = new Map() + const lists = new Map() + const claim = (action: ActionDef, accels: readonly string[]) => { + const list: string[] = [] + for (const accel of accels) { + const key = canonicalAccelerator(accel) + if (!key || owner.has(key)) continue + owner.set(key, action) + list.push(accel) + } + lists.set(action.id, list) + } + for (const action of ACTIONS) if (action.fixed) claim(action, defaultShortcutsFor(action, platform)) + for (const action of ACTIONS) if (ownsList(action, custom)) claim(action, custom[action.id] ?? []) + for (const action of ACTIONS) { + if (!action.fixed && !ownsList(action, custom)) claim(action, defaultShortcutsFor(action, platform)) + } + const direct = new Map() + const outsideText = new Map() + for (const [key, action] of owner) (editsText(key, platform) ? outsideText : direct).set(key, action) + return { lists, direct, outsideText } } -export interface ParsedAccelerator { - cmd: boolean - ctrl: boolean - alt: boolean - shift: boolean - key: string -} +let current: { version: number; platform: string; keymap: Keymap } | undefined -export function parseAccelerator(accel: string): ParsedAccelerator { - const parts = accel.split('+') - // "Cmd+=" style keys are never "+" themselves; a trailing empty part means the key was "+". - const key = parts.pop() || '+' - const mods = new Set(parts.map((p) => p.toLowerCase())) - return { - cmd: mods.has('cmd') || mods.has('command') || mods.has('meta'), - ctrl: mods.has('ctrl') || mods.has('control'), - alt: mods.has('alt') || mods.has('option'), - shift: mods.has('shift'), - key: key.length === 1 ? key.toUpperCase() : key, +/** The keymap in use now (KS-01): rebuilt when the user's bindings change. */ +function liveIndex(platform: string): Keymap { + const version = keymapVersion() + if (current?.version !== version || current.platform !== platform) { + current = { version, platform, keymap: buildKeymap(customBindings(), platform) } } + return current.keymap } -export function matchesAccelerator(input: KeyInput, accel: string): boolean { - const a = parseAccelerator(accel) - return ( - a.cmd === input.meta && - a.ctrl === input.control && - a.alt === input.alt && - a.shift === input.shift && - a.key === keyName(input) - ) +/** + * The action's shortcuts on this platform as they are now (KS-01): the user's bindings if they + * changed them, else the defaults. The first is the one hints show. + */ +export function shortcutsFor(action: ActionDef, platform: string): string[] { + return liveIndex(platform).lists.get(action.id) ?? [] +} + +/** The action that owns a binding now, if any (Settings → Shortcuts' conflict check). */ +export function actionForAccelerator(accel: string, platform: string): ActionDef | undefined { + const key = canonicalAccelerator(accel) + if (!key) return undefined + const keymap = liveIndex(platform) + return keymap.direct.get(key) ?? keymap.outsideText.get(key) } +/** An IME is composing: only keys with ⌘ or Ctrl may act (KS-16). */ +const composingPlainKey = (input: KeyInput) => !!input.isComposing && !input.meta && !input.control + /** Returns the action bound to this key press on the given platform, if any. */ export function actionForInput(input: KeyInput, platform: string): ActionDef | undefined { - for (const action of ACTIONS) { - for (const accel of shortcutsFor(action, platform)) { - if (matchesAccelerator(input, accel)) return action - } - } - return undefined + // A key that typed a character through AltGr acts only outside text fields (KS-03). + if (composingPlainKey(input) || typedWithAltGr(input, platform)) return undefined + const key = acceleratorFromInput(input) + return key ? liveIndex(platform).direct.get(key) : undefined } /** @@ -536,30 +575,9 @@ export function actionForInput(input: KeyInput, platform: string): ActionDef | u * The caller checks where focus is before running it. */ export function outsideTextActionFor(input: KeyInput, platform: string): ActionDef | undefined { - for (const action of ACTIONS) { - const keys = (platform === 'darwin' ? action.macOutsideText : action.otherOutsideText) ?? [] - if (keys.some((accel) => matchesAccelerator(input, accel))) return action - } - return undefined -} - -/** Display form of an accelerator, e.g. "⌘⇧T" on macOS or "Ctrl+Shift+T" elsewhere. */ -export function formatAccelerator(accel: string, platform: string): string { - const a = parseAccelerator(accel) - const keyLabel = ( - { Left: '←', Right: '→', Up: '↑', Down: '↓', Enter: '↩', Escape: 'Esc', Space: 'Space' } as Record - )[a.key] - if (platform === 'darwin') { - return `${a.ctrl ? '⌃' : ''}${a.alt ? '⌥' : ''}${a.shift ? '⇧' : ''}${a.cmd ? '⌘' : ''}${keyLabel ?? a.key}` - } - return [a.ctrl && 'Ctrl', a.alt && 'Alt', a.shift && 'Shift', a.cmd && 'Meta', keyLabel ?? a.key] - .filter(Boolean) - .join('+') -} - -/** Electron menu accelerator form ("CommandOrControl" etc.). */ -export function toElectronAccelerator(accel: string): string { - const a = parseAccelerator(accel) - const key = a.key === '=' ? '=' : a.key === '+' ? 'Plus' : a.key - return [a.cmd && 'Command', a.ctrl && 'Control', a.alt && 'Alt', a.shift && 'Shift', key].filter(Boolean).join('+') + if (composingPlainKey(input)) return undefined + const key = acceleratorFromInput(input) + if (!key) return undefined + const keymap = liveIndex(platform) + return keymap.outsideText.get(key) ?? (typedWithAltGr(input, platform) ? keymap.direct.get(key) : undefined) } diff --git a/src/shared/commands/keys.ts b/src/shared/commands/keys.ts new file mode 100644 index 00000000..91474928 --- /dev/null +++ b/src/shared/commands/keys.ts @@ -0,0 +1,60 @@ +/** + * Owned by the keys lane: the keymap's commands (KS-01, KS-02) and Settings → Shortcuts (ST-08). + * Bindings are accelerator strings (src/shared/keymap.ts); actions are ids from src/shared/actions.ts. + */ + +/** Who gets a key first when a page also handles it (KS-02). */ +export type ShortcutPriority = 'websites' | 'orbit' + +/** Another action that already has the binding asked for (ST-08 conflict warning). */ +export interface BindingConflict { + actionId: string + title: string +} + +export type BindResult = + | { ok: true } + /** The binding can't be used: no modifier, a key Orbit doesn't bind, one the system keeps… */ + | { ok: false; problem: string } + /** Another action has it; ask again with `onConflict` to take it. */ + | { ok: false; conflict: BindingConflict } + +export interface BindRequest { + actionId: string + /** The new binding, e.g. "Cmd+Shift+B". */ + accel: string + /** One of the action's bindings the new one takes the place of; without it, the new one is added. */ + replace?: string + /** + * When another action has `accel`: 'clear' takes it off that action; 'swap' gives that action + * `replace` in its place (or clears it when there is nothing to give). Without it, the conflict + * is returned and nothing changes. + */ + onConflict?: 'clear' | 'swap' +} + +/** What main reports to Settings → Shortcuts while it records a binding. */ +export interface RecordedKey { + /** The binding the press made, written as `canonicalAccelerator` writes it; absent for a lone modifier. */ + accel?: string + /** The modifiers held, e.g. "Cmd+Shift" ("" for none), to show while the user builds a combination. */ + modifiers: string +} + +export interface KeyCommands { + /** Gives an action a binding (KS-01, ST-08). See BindRequest. */ + 'keys.bind': { args: BindRequest; result: BindResult } + /** Takes one binding off an action; the command bar and menus still reach it (KS-01). */ + 'keys.unbind': { args: { actionId: string; accel: string }; result: void } + /** Puts an action's bindings back to its defaults, taking each back from any action that has it now. */ + 'keys.reset': { args: { actionId: string }; result: void } + /** Puts every action's bindings and priorities back to the defaults. */ + 'keys.resetAll': { args: Record; result: void } + /** + * Settings → Shortcuts records a binding: while on, each key press in the Settings window is + * sent to it as `keys.recorded` (a RecordedKey) and runs nothing. Off when the window closes. + */ + 'keys.record': { args: { on: boolean }; result: void } + /** Who gets the action's keys first when a page also handles them (KS-02). */ + 'keys.setPriority': { args: { actionId: string; priority: ShortcutPriority }; result: void } +} diff --git a/src/shared/keymap.ts b/src/shared/keymap.ts new file mode 100644 index 00000000..9f73bdf2 --- /dev/null +++ b/src/shared/keymap.ts @@ -0,0 +1,428 @@ +/** + * The keymap's building blocks (KS-01, KS-03): accelerator strings, how a key press matches them, + * what a new binding must look like, and the bindings the user changed on this device. + * + * An accelerator is modifiers (Cmd, Ctrl, Alt, Shift) joined by "+" with a key name: A–Z, 0–9, + * [ ] , . / ; ' ` - = \, Left Right Up Down, Tab Enter Escape Backspace Delete Space Home End + * PageUp PageDown Insert, F1–F24. Cmd is ⌘ on macOS and the Windows/Super key elsewhere. + * + * Each action's default bindings are its `mac` / `other` lists (src/shared/actions.ts). The user's + * changes are `settings.shortcuts`: for each action they changed, its whole list of bindings (an + * empty list when they took every key away; the command bar and menus still reach the action). + * Main and every Orbit renderer keep this module's copy in step with that setting + * (`setCustomBindings`), so a remap reaches the key router, the menus, tooltips, command-bar rows + * and the shortcut sheet at once. + */ + +export interface KeyInput { + key: string + code: string + meta: boolean + control: boolean + alt: boolean + shift: boolean + /** An input method (IME) is composing text: keys without ⌘ or Ctrl belong to it (KS-16). */ + isComposing?: boolean +} + +const CODE_NAMES: Record = { + BracketLeft: '[', + BracketRight: ']', + Comma: ',', + Period: '.', + Slash: '/', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Minus: '-', + Equal: '=', + ArrowLeft: 'Left', + ArrowRight: 'Right', + ArrowUp: 'Up', + ArrowDown: 'Down', + NumpadAdd: '=', + NumpadSubtract: '-', + NumpadEnter: 'Enter', +} + +/** Shifted ASCII symbols (US positions) → the unshifted key they share, so "Cmd+Shift+=" matches "+". */ +const SHIFTED_SYMBOLS: Record = { + '+': '=', + _: '-', + '{': '[', + '}': ']', + '<': ',', + '>': '.', + '?': '/', + ':': ';', + '"': "'", + '~': '`', + '|': '\\', +} +const ASCII_SYMBOL = /^[!-/:-@[-`{-~]$/ + +/** + * Key name for an input, matched against accelerator keys. Like Chrome: + * - letters follow the keyboard layout (`key`), so AZERTY's Cmd+Z is undo, not the physical W key; + * - digits use the physical key (`code`), so AZERTY's unshifted digit row still selects tabs; + * - ASCII symbols follow the layout too (Dvorak's "." sits on the physical E key); + * - anything else (macOS Option combos, Cyrillic and other non-Latin layouts, dead keys) falls + * back to the physical key, so Cmd+Option+V or Ctrl+T on a Russian layout still work. + */ +export function keyName(input: Pick): string { + const { code, key } = input + if (/^[a-z]$/i.test(key)) return key.toUpperCase() + if (code.startsWith('Digit') && code.length === 6) return code.slice(5) + if (code.startsWith('Numpad') && code.length === 7 && /\d/.test(code[6] ?? '')) return code.slice(6) + if (ASCII_SYMBOL.test(key)) return SHIFTED_SYMBOLS[key] ?? key + if (code.startsWith('Key') && code.length === 4) return code.slice(3) + if (CODE_NAMES[code]) return CODE_NAMES[code] as string + if (key === ' ') return 'Space' + return key.length === 1 ? key.toUpperCase() : key +} + +export interface ParsedAccelerator { + cmd: boolean + ctrl: boolean + alt: boolean + shift: boolean + key: string +} + +export function parseAccelerator(accel: string): ParsedAccelerator { + const parts = accel.split('+') + // "Cmd+=" style keys are never "+" themselves; a trailing empty part means the key was "+". + const key = parts.pop() || '+' + const mods = new Set(parts.map((p) => p.toLowerCase())) + return { + cmd: mods.has('cmd') || mods.has('command') || mods.has('meta'), + ctrl: mods.has('ctrl') || mods.has('control'), + alt: mods.has('alt') || mods.has('option'), + shift: mods.has('shift'), + key: key.length === 1 ? key.toUpperCase() : key, + } +} + +export function matchesAccelerator(input: KeyInput, accel: string): boolean { + const a = parseAccelerator(accel) + return ( + a.cmd === input.meta && + a.ctrl === input.control && + a.alt === input.alt && + a.shift === input.shift && + a.key === keyName(input) + ) +} + +const KEY_LABELS: Record = { + Left: '←', + Right: '→', + Up: '↑', + Down: '↓', + Enter: '↩', + Escape: 'Esc', + Space: 'Space', +} +const MAC_KEY_LABELS: Record = { Backspace: '⌫', Delete: '⌦', Tab: '⇥', PageUp: '⇞', PageDown: '⇟' } + +/** Display form of an accelerator, e.g. "⌘⇧T" on macOS or "Ctrl+Shift+T" elsewhere. */ +export function formatAccelerator(accel: string, platform: string): string { + const a = parseAccelerator(accel) + const keyLabel = KEY_LABELS[a.key] ?? (platform === 'darwin' ? MAC_KEY_LABELS[a.key] : undefined) + if (platform === 'darwin') { + return `${a.ctrl ? '⌃' : ''}${a.alt ? '⌥' : ''}${a.shift ? '⇧' : ''}${a.cmd ? '⌘' : ''}${keyLabel ?? a.key}` + } + return [a.ctrl && 'Ctrl', a.alt && 'Alt', a.shift && 'Shift', a.cmd && 'Meta', keyLabel ?? a.key] + .filter(Boolean) + .join('+') +} + +/** Electron menu accelerator form ("CommandOrControl" etc.). */ +export function toElectronAccelerator(accel: string): string { + const a = parseAccelerator(accel) + const key = a.key === '=' ? '=' : a.key === '+' ? 'Plus' : a.key + return [a.cmd && 'Command', a.ctrl && 'Control', a.alt && 'Alt', a.shift && 'Shift', key].filter(Boolean).join('+') +} + +// ---- Bindings: one written form, and what a new one must be (KS-01, ST-08) ---- + +const NAMED_KEYS = [ + 'Left', + 'Right', + 'Up', + 'Down', + 'Tab', + 'Enter', + 'Escape', + 'Backspace', + 'Delete', + 'Space', + 'Home', + 'End', + 'PageUp', + 'PageDown', + 'Insert', +] +const NAMED_BY_LOWER = new Map([ + ...NAMED_KEYS.map((k): [string, string] => [k.toLowerCase(), k]), + ['esc', 'Escape'], + ['return', 'Enter'], + ['plus', '='], +]) +const SYMBOL_KEYS = new Set(['[', ']', ',', '.', '/', ';', "'", '`', '-', '=', '\\']) +const FUNCTION_KEY = /^F([1-9]|1\d|2[0-4])$/ +const MODIFIER_NAMES: Record = { + cmd: 'Cmd', + command: 'Cmd', + meta: 'Cmd', + ctrl: 'Ctrl', + control: 'Ctrl', + alt: 'Alt', + option: 'Alt', + shift: 'Shift', +} + +/** The key part of an accelerator in its one written form, or undefined if Orbit doesn't bind it. */ +function canonicalKey(raw: string): string | undefined { + if (raw.length === 1) { + const upper = raw.toUpperCase() + if (/^[A-Z0-9]$/.test(upper)) return upper + const base = SHIFTED_SYMBOLS[raw] ?? raw + return SYMBOL_KEYS.has(base) ? base : undefined + } + const named = NAMED_BY_LOWER.get(raw.toLowerCase()) + if (named) return named + const fKey = raw.toUpperCase() + return FUNCTION_KEY.test(fKey) ? fKey : undefined +} + +/** + * An accelerator in its one written form: modifiers in the order Cmd, Ctrl, Alt, Shift, then the key + * ("Shift+Cmd+t" → "Cmd+Shift+T", "Cmd++" → "Cmd+="). Undefined when it isn't one Orbit can bind: + * an unknown modifier or key, or a modifier twice. + */ +export function canonicalAccelerator(accel: string): string | undefined { + if (typeof accel !== 'string' || accel.length === 0 || accel.length > 64) return undefined + const parts = accel.split('+') + // "Cmd++": the key is "+" itself. + let rawKey = parts.pop() ?? '' + if (rawKey === '' && parts.length > 0 && parts[parts.length - 1] === '') { + parts.pop() + rawKey = '+' + } + const key = canonicalKey(rawKey) + if (!key) return undefined + const mods = new Set() + for (const part of parts) { + const mod = MODIFIER_NAMES[part.toLowerCase()] + if (!mod || mods.has(mod)) return undefined + mods.add(mod) + } + return [...(['Cmd', 'Ctrl', 'Alt', 'Shift'] as const).filter((m) => mods.has(m)), key].join('+') +} + +/** Keys that are only modifiers (or locks): pressed alone they make no binding. */ +const MODIFIER_KEYS = new Set([ + 'Shift', + 'Control', + 'Alt', + 'AltGraph', + 'Meta', + 'OS', + 'Super', + 'Hyper', + 'Fn', + 'FnLock', + 'CapsLock', + 'NumLock', + 'ScrollLock', + 'Symbol', + 'SymbolLock', +]) + +/** The modifiers held in a key press, as a binding writes them; "" when none. */ +export function modifiersOf(input: Pick): string { + return [input.meta && 'Cmd', input.control && 'Ctrl', input.alt && 'Alt', input.shift && 'Shift'] + .filter(Boolean) + .join('+') +} + +/** + * The binding a key press makes (written as `canonicalAccelerator` writes it), e.g. for Settings → + * Shortcuts recording a new one (ST-08). Undefined for a modifier pressed on its own and for keys + * Orbit doesn't bind. + */ +export function acceleratorFromInput(input: KeyInput): string | undefined { + if (MODIFIER_KEYS.has(input.key)) return undefined + const mods = modifiersOf(input) + return canonicalAccelerator(mods ? `${mods}+${keyName(input)}` : keyName(input)) +} + +/** Function keys F1–F12 may be bound with no modifier (KS-01). */ +const BARE_KEY = /^F([1-9]|1[0-2])$/ + +/** Keys the system or Orbit keeps for themselves (KS-15, KS-12): not offered as bindings. */ +const RESERVED: Record<'mac' | 'other', Record> = { + mac: { + 'Cmd+Q': 'Quit Orbit', + 'Cmd+H': 'Hide Orbit', + 'Cmd+Alt+H': 'Hide Others', + 'Cmd+M': 'Minimize', + 'Cmd+`': 'switching between Orbit windows', + 'Cmd+Shift+/': 'Help menu search', + 'Cmd+Tab': 'switching apps', + 'Cmd+Space': 'system search', + }, + other: { + 'Alt+F4': 'closing the window', + 'Alt+Tab': 'switching apps', + 'Alt+F': 'the app menu', + }, +} + +/** What keeps a binding from being used, as Settings → Shortcuts says it; undefined when it is fine. */ +export function bindingProblem(accel: string, platform: string): string | undefined { + const canonical = canonicalAccelerator(accel) + if (!canonical) return "Orbit can't use that key for a shortcut." + const a = parseAccelerator(canonical) + const mac = platform === 'darwin' + if (!a.cmd && !a.ctrl && !a.alt && !a.shift && !BARE_KEY.test(a.key)) { + return mac + ? 'A shortcut starts with ⇧, ⌃, ⌥ or ⌘ (F1–F12 can be used on their own).' + : 'A shortcut starts with Shift, Ctrl or Alt (F1–F12 can be used on their own).' + } + const taken = RESERVED[mac ? 'mac' : 'other'][canonical] + if (taken) return `${formatAccelerator(canonical, platform)} is kept for ${taken}.` + return undefined +} + +// ---- Keys that type or edit text (KS-03, KS-16) ---- + +const MAC_TEXT_COMMANDS = new Set(['Cmd+A', 'Cmd+C', 'Cmd+X', 'Cmd+V', 'Cmd+Z', 'Cmd+Shift+Z', 'Cmd+Alt+Shift+V']) +const OTHER_TEXT_COMMANDS = new Set([ + 'Ctrl+A', + 'Ctrl+C', + 'Ctrl+X', + 'Ctrl+V', + 'Ctrl+Z', + 'Ctrl+Y', + 'Ctrl+Shift+Z', + 'Ctrl+Shift+V', + 'Ctrl+Insert', + 'Shift+Insert', + 'Shift+Delete', +]) + +type Modifier = 'cmd' | 'ctrl' | 'alt' | 'shift' + +/** True when every modifier the binding holds is one of `allowed` (none held counts). */ +function holdsOnly(a: ParsedAccelerator, ...allowed: Modifier[]): boolean { + return (['cmd', 'ctrl', 'alt', 'shift'] as const).every((m) => !a[m] || allowed.includes(m)) +} + +/** Caret, selection and deletion keys with the modifiers that make them edit text, per platform. */ +function editingKeyEdits(a: ParsedAccelerator, mac: boolean): boolean | undefined { + switch (a.key) { + case 'Left': + case 'Right': + case 'Up': + case 'Down': + // macOS: ⌘ moves by line, ⌥ by word, ⇧ selects (⌘⌥ arrows edit nothing). Elsewhere Ctrl + // moves by word and Shift selects; Alt+←/→ go back and forward. + return mac ? holdsOnly(a, 'cmd', 'shift') || holdsOnly(a, 'alt', 'shift') : holdsOnly(a, 'ctrl', 'shift') + case 'Home': + case 'End': + return mac ? holdsOnly(a, 'shift') : holdsOnly(a, 'ctrl', 'shift') + case 'PageUp': + case 'PageDown': + // Ctrl+PageUp/PageDown switch tabs, even from a field. + return holdsOnly(a, 'shift') + case 'Backspace': + case 'Delete': + // One of ⌘ (to line start), ⌥ (word) or ⇧ on macOS; Ctrl (word) elsewhere. ⌘⇧⌫ and + // Ctrl+Shift+Delete edit nothing (they clear browsing data). + return mac + ? holdsOnly(a, 'cmd') || holdsOnly(a, 'alt') || holdsOnly(a, 'shift') + : holdsOnly(a, 'ctrl') || holdsOnly(a, 'shift') + case 'Enter': + case 'Space': + return mac ? holdsOnly(a, 'alt', 'shift') : holdsOnly(a, 'shift') + case 'Tab': + return holdsOnly(a, 'shift') + case 'Insert': + return holdsOnly(a, 'ctrl', 'shift') + default: + return undefined + } +} + +/** + * Whether a binding is a key that types or edits text in a field on this platform (KS-16): a + * printable key with no ⌘ or Ctrl (Shift types capitals; macOS's ⌥ types symbols), the caret, + * selection and deletion keys with the modifiers that move by word or line, and the clipboard and + * undo commands. Such a binding runs its action only while keyboard focus is outside text fields, + * and the key is never taken from the field. + */ +export function editsText(accel: string, platform: string): boolean { + const canonical = canonicalAccelerator(accel) + if (!canonical) return false + const mac = platform === 'darwin' + if ((mac ? MAC_TEXT_COMMANDS : OTHER_TEXT_COMMANDS).has(canonical)) return true + const a = parseAccelerator(canonical) + if (FUNCTION_KEY.test(a.key) || a.key === 'Escape') return false + const editing = editingKeyEdits(a, mac) + if (editing !== undefined) return editing + // A printable key types a character unless ⌘ or Ctrl is held (outside macOS, Alt too). + return mac ? !a.cmd && !a.ctrl : !a.cmd && !a.ctrl && !a.alt +} + +/** + * Whether this key press typed a character that a plain press of the same key would not: on + * Windows, AltGr arrives as Ctrl+Alt, so a Ctrl+Alt binding must not fire when the press made "ń" + * or "@" in a text field (KS-03). + */ +export function typedWithAltGr(input: KeyInput, platform: string): boolean { + if (platform === 'darwin' || !input.control || !input.alt || input.meta) return false + if ([...input.key].length !== 1) return false + if (input.code.startsWith('Key') && input.key.toUpperCase() === input.code.slice(3)) return false + if (input.code.startsWith('Digit') && input.key === input.code.slice(5)) return false + // The symbol the key carries on a US layout, shifted or not: "[" from BracketLeft is no AltGr character. + const symbol = CODE_NAMES[input.code] + if (symbol !== undefined && symbol.length === 1 && (SHIFTED_SYMBOLS[input.key] ?? input.key) === symbol) return false + return true +} + +// ---- The user's bindings on this device (KS-01) ---- + +/** Action id → its whole binding list, for actions whose bindings the user changed. */ +export type CustomBindings = Readonly> + +const NONE: CustomBindings = Object.freeze({}) +let custom: CustomBindings = NONE +let version = 0 +const listeners = new Set<() => void>() + +/** Takes the user's bindings (`settings.shortcuts`) as they are now; listeners hear of a change. */ +export function setCustomBindings(next: CustomBindings | undefined): void { + const value = next ?? NONE + if (value === custom) return + custom = value + version++ + for (const listener of [...listeners]) listener() +} + +export function customBindings(): CustomBindings { + return custom +} + +/** Goes up by one each time the user's bindings change: caches of hints compare it. */ +export function keymapVersion(): number { + return version +} + +/** Calls `listener` after each change of the user's bindings; returns the unsubscribe. */ +export function onKeymapChange(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} diff --git a/src/shared/rpc.ts b/src/shared/rpc.ts index dda1c93f..5eebe940 100644 --- a/src/shared/rpc.ts +++ b/src/shared/rpc.ts @@ -15,6 +15,7 @@ import type { DownloadCommands } from './commands/downloads' import type { ExtensionCommands } from './commands/extensions' import type { HistoryCommands } from './commands/history' import type { ImportCommands } from './commands/import' +import type { KeyCommands } from './commands/keys' import type { MediaCommands, MediaEvents } from './commands/media' import type { NavCommands } from './commands/nav' import type { PeekCommands, PeekEvents } from './commands/peek' @@ -39,7 +40,8 @@ export interface OrbitCommands DownloadCommands, ImportCommands, NavCommands, - MediaCommands {} + MediaCommands, + KeyCommands {} export interface OrbitEvents extends CoreEvents, PeekEvents, MediaEvents {} diff --git a/src/shared/state.ts b/src/shared/state.ts index 07c483de..62cf241e 100644 --- a/src/shared/state.ts +++ b/src/shared/state.ts @@ -333,6 +333,14 @@ export interface Settings { countFavoritesInShortcuts: boolean /** ⌘9 selects the last tab (true) or the 9th item (false) (TB-04). */ lastTabOnCmd9: boolean + /** + * Key bindings the user changed on this device (KS-01, ST-08): action id → its whole list of + * bindings (accelerator strings; empty when every key was taken away). Actions not listed use + * their defaults. Kept by src/main/keys; never synced. + */ + shortcuts?: Record + /** Who gets an action's keys first when a page also handles them, where the user chose (KS-02). */ + shortcutPriority?: Record /** Use graphics acceleration when available (ST-12). Takes effect at the next start. */ hardwareAcceleration: boolean /** Secure DNS (ST-12): the engine's automatic upgrade, off, or always one DNS-over-HTTPS provider. */ diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts index 6dc0513d..9ef0b4e9 100644 --- a/tests/e2e/keys.spec.ts +++ b/tests/e2e/keys.spec.ts @@ -1,6 +1,7 @@ import { expect, test } from '@playwright/test' -import { launchOrbit, MOD, type Orbit, run, state, viewPage } from './helpers' +import { action, isMac, launchOrbit, MOD, type Orbit, run, state, viewPage } from './helpers' import { cmd, type NavFixtures, openTab, startNavFixtures } from './nav-support' +import { activeSpace, createSpace } from './spaces-support' let fx: NavFixtures let orbit: Orbit @@ -92,3 +93,111 @@ test('[KS-16] a tab in the background that loads again does not take the keyboar await finishLoad(back, '/slow/BackAgain') expect(await typeAnywhere(['b', 'c'])).toEqual([fx.url('/page/Front'), fx.url('/page/Front')]) }) + +// ---------- One keymap: remapping, several bindings, persistence (KS-01, KS-18) ---------- + +/** The primary modifier as bindings write it. */ +const PRIMARY = isMac ? 'Cmd' : 'Ctrl' +const SHIFT_B = isMac ? '⇧⌘B' : 'Ctrl+Shift+B' + +async function sidebarVisible(): Promise { + return Object.values((await state(orbit.app)).windows)[0]?.sidebar.visible +} + +/** Opens the command bar, types `text` and returns the top row once it is the action `title`. */ +async function actionRow(text: string, title: string) { + await orbit.shell.keyboard.press(`${MOD}+T`) + const overlay = await viewPage(orbit.app, 'overlay') + await expect(overlay.getByTestId('command-input')).toBeFocused() + await overlay.keyboard.type(text) + const top = overlay.getByTestId('command-result').first() + await expect(top).toHaveAttribute('data-kind', 'action') + await expect(top.getByTestId('command-result-title')).toHaveText(title) + return { overlay, top } +} + +/** ⌘⇧B toggles the sidebar, and ⌘S no longer does. */ +async function onlyShiftBToggles(): Promise { + const { shell } = orbit + await shell.keyboard.press(`${MOD}+Shift+B`) + await expect.poll(sidebarVisible).toBe(false) + // Were ⌘S still bound, these two presses would toggle twice and leave the sidebar hidden. + await shell.keyboard.press(`${MOD}+S`) + await shell.keyboard.press(`${MOD}+Shift+B`) + await expect.poll(sidebarVisible).toBe(true) +} + +test('[KS-01][KS-18] remapping Toggle Sidebar to ⌘⇧B moves the key and every hint, and the remap and a Space key survive a restart', async () => { + const { app, shell, userData } = orbit + const bound = await run(app, 'keys.bind', { + actionId: 'sidebar.toggle', + accel: `${PRIMARY}+Shift+B`, + replace: `${PRIMARY}+S`, + }) + expect(bound).toEqual({ ok: true }) + // A bare function key may be a binding on its own: Space 2 moves to F8. + const defaultSpaceKey = isMac ? 'Ctrl+2' : 'Alt+2' + expect(await run(app, 'keys.bind', { actionId: 'space.goto.2', accel: 'F8', replace: defaultSpaceKey })).toEqual({ + ok: true, + }) + // The sidebar button's tooltip follows the remap at once. + await expect(shell.getByTestId('toggle-sidebar')).toHaveAttribute('title', `Hide sidebar (${SHIFT_B})`) + await onlyShiftBToggles() + // So does the command bar's row for the action. + const { overlay, top } = await actionRow('Toggle Sidebar', 'Toggle Sidebar') + await expect(top.getByTestId('command-shortcut')).toHaveText(SHIFT_B) + await overlay.keyboard.press('Escape') + if (isMac) { + const menuKey = await app.evaluate( + ({ Menu }) => + Menu.getApplicationMenu() + ?.items.find((i) => i.label === 'View') + ?.submenu?.items.find((i) => i.label === 'Toggle Sidebar')?.accelerator, + ) + expect(menuKey).toBe('Command+Shift+B') + } + const first = await activeSpace(app) + const second = await createSpace(app, { name: 'Second', activate: false }) + await shell.keyboard.press('F8') + await expect.poll(() => activeSpace(app)).toBe(second) + + await orbit.app.close() + orbit = await launchOrbit({ userData }) + expect((await state(orbit.app)).settings.shortcuts).toEqual({ + 'sidebar.toggle': [`${PRIMARY}+Shift+B`], + 'space.goto.2': ['F8'], + }) + await expect(orbit.shell.getByTestId('toggle-sidebar')).toHaveAttribute('title', `Hide sidebar (${SHIFT_B})`) + await onlyShiftBToggles() + await action(orbit.app, 'space.goto.1') + await expect.poll(() => activeSpace(orbit.app)).toBe(first) + await orbit.shell.keyboard.press('F8') + await expect.poll(() => activeSpace(orbit.app)).toBe(second) +}) + +test('[KS-01] an action keeps several bindings, or none and the command bar still runs it; a binding needs a modifier and a free key', async () => { + const { app, shell } = orbit + expect(await run(app, 'keys.bind', { actionId: 'sidebar.toggle', accel: 'F7' })).toEqual({ ok: true }) + await shell.keyboard.press('F7') + await expect.poll(sidebarVisible).toBe(false) + await shell.keyboard.press(`${MOD}+S`) + await expect.poll(sidebarVisible).toBe(true) + + // No modifier, or a key another action has: refused, and nothing changes. + const bare = await run(app, 'keys.bind', { actionId: 'sidebar.toggle', accel: 'B' }) + expect(bare).toMatchObject({ ok: false, problem: expect.stringContaining('A shortcut starts with') }) + const taken = await run(app, 'keys.bind', { actionId: 'sidebar.toggle', accel: `${PRIMARY}+T` }) + expect(taken).toEqual({ ok: false, conflict: { actionId: 'tab.new', title: 'New Tab' } }) + expect((await state(app)).settings.shortcuts).toEqual({ 'sidebar.toggle': [`${PRIMARY}+S`, 'F7'] }) + + await run(app, 'keys.unbind', { actionId: 'sidebar.toggle', accel: `${PRIMARY}+S` }) + await run(app, 'keys.unbind', { actionId: 'sidebar.toggle', accel: 'F7' }) + expect((await state(app)).settings.shortcuts).toEqual({ 'sidebar.toggle': [] }) + await expect(shell.getByTestId('toggle-sidebar')).toHaveAttribute('title', 'Hide sidebar') + // ⌘S does nothing now; the command bar's row, with no shortcut shown, still toggles the sidebar. + await shell.keyboard.press(`${MOD}+S`) + const { overlay, top } = await actionRow('Toggle Sidebar', 'Toggle Sidebar') + await expect(top.getByTestId('command-shortcut')).toHaveCount(0) + await overlay.keyboard.press('Enter') + await expect.poll(sidebarVisible).toBe(false) +}) diff --git a/tests/unit/keymap-bindings.test.ts b/tests/unit/keymap-bindings.test.ts new file mode 100644 index 00000000..66c49ced --- /dev/null +++ b/tests/unit/keymap-bindings.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { actionForInput, outsideTextActionFor } from '../../src/shared/actions' +import { + acceleratorFromInput, + bindingProblem, + canonicalAccelerator, + editsText, + formatAccelerator, + type KeyInput, + typedWithAltGr, +} from '../../src/shared/keymap' + +const press = (key: string, code: string, mods: Partial = {}): KeyInput => ({ + key, + code, + meta: false, + control: false, + alt: false, + shift: false, + ...mods, +}) + +describe('[KS-01] bindings have one written form', () => { + it('orders modifiers and names keys one way', () => { + expect(canonicalAccelerator('Shift+Cmd+t')).toBe('Cmd+Shift+T') + expect(canonicalAccelerator('control+option+shift+n')).toBe('Ctrl+Alt+Shift+N') + expect(canonicalAccelerator('Command+Meta+K')).toBeUndefined() + expect(canonicalAccelerator('Cmd++')).toBe('Cmd+=') + expect(canonicalAccelerator('Cmd+Shift+=')).toBe('Cmd+Shift+=') + expect(canonicalAccelerator('Ctrl+Shift+{')).toBe('Ctrl+Shift+[') + expect(canonicalAccelerator('alt+left')).toBe('Alt+Left') + expect(canonicalAccelerator('Cmd+esc')).toBe('Cmd+Escape') + expect(canonicalAccelerator('f5')).toBe('F5') + expect(canonicalAccelerator('Shift+F24')).toBe('Shift+F24') + }) + + it('refuses what is not a binding', () => { + for (const bad of ['', 'Cmd+', 'Hyper+A', 'Cmd+Cmd+A', 'Cmd+Ö', 'Cmd+F25', 'Cmd+MediaPlayPause', 'Cmd+Shift']) { + expect(canonicalAccelerator(bad), bad).toBeUndefined() + } + }) + + it('reads a key press as the binding it makes, and nothing for a lone modifier', () => { + expect(acceleratorFromInput(press('b', 'KeyB', { meta: true, shift: true }))).toBe('Cmd+Shift+B') + expect(acceleratorFromInput(press('B', 'KeyB', { control: true, shift: true }))).toBe('Ctrl+Shift+B') + expect(acceleratorFromInput(press('+', 'Equal', { meta: true, shift: true }))).toBe('Cmd+Shift+=') + expect(acceleratorFromInput(press('F7', 'F7'))).toBe('F7') + expect(acceleratorFromInput(press('Shift', 'ShiftLeft', { shift: true }))).toBeUndefined() + expect(acceleratorFromInput(press('Meta', 'MetaLeft', { meta: true }))).toBeUndefined() + }) +}) + +describe('[KS-01] what a new binding must be', () => { + it('needs a modifier, except F1–F12 on their own', () => { + expect(bindingProblem('B', 'darwin')).toBe('A shortcut starts with ⇧, ⌃, ⌥ or ⌘ (F1–F12 can be used on their own).') + expect(bindingProblem('Enter', 'linux')).toBe( + 'A shortcut starts with Shift, Ctrl or Alt (F1–F12 can be used on their own).', + ) + expect(bindingProblem('F7', 'darwin')).toBeUndefined() + expect(bindingProblem('F12', 'linux')).toBeUndefined() + expect(bindingProblem('F13', 'linux')).toBeDefined() + expect(bindingProblem('Shift+F13', 'linux')).toBeUndefined() + expect(bindingProblem('Shift+B', 'darwin')).toBeUndefined() + expect(bindingProblem('Cmd+Shift+B', 'darwin')).toBeUndefined() + }) + + it('keeps the system’s own keys', () => { + expect(bindingProblem('Cmd+Q', 'darwin')).toBe('⌘Q is kept for Quit Orbit.') + expect(bindingProblem('Cmd+M', 'darwin')).toBe('⌘M is kept for Minimize.') + expect(bindingProblem('Cmd+Q', 'linux')).toBeUndefined() + expect(bindingProblem('Alt+F4', 'win32')).toBe('Alt+F4 is kept for closing the window.') + expect(bindingProblem('Alt+F', 'linux')).toBe('Alt+F is kept for the app menu.') + }) + + it('says when Orbit cannot use the key at all', () => { + expect(bindingProblem('Cmd+Ö', 'darwin')).toBe("Orbit can't use that key for a shortcut.") + }) + + it('shows bindings as each platform writes them', () => { + expect(formatAccelerator('Cmd+Shift+B', 'darwin')).toBe('⇧⌘B') + expect(formatAccelerator('Cmd+Shift+Backspace', 'darwin')).toBe('⇧⌘⌫') + expect(formatAccelerator('Ctrl+Shift+Delete', 'linux')).toBe('Ctrl+Shift+Delete') + expect(formatAccelerator('Ctrl+PageDown', 'linux')).toBe('Ctrl+PageDown') + }) +}) + +describe('[KS-16] keys that edit text act only outside text fields', () => { + it('knows the caret, selection, clipboard and undo keys of each platform', () => { + for (const accel of ['Cmd+Left', 'Cmd+Shift+Right', 'Alt+Left', 'Cmd+Z', 'Cmd+Shift+Z', 'Cmd+A', 'Cmd+V']) { + expect(editsText(accel, 'darwin'), accel).toBe(true) + } + for (const accel of ['Ctrl+Left', 'Ctrl+Shift+Right', 'Ctrl+Z', 'Ctrl+Y', 'Ctrl+Backspace', 'Shift+Insert']) { + expect(editsText(accel, 'linux'), accel).toBe(true) + } + // Back and forward, tab and Space switching, clearing data: they edit nothing. + for (const accel of ['Cmd+[', 'Cmd+Alt+Left', 'Cmd+Alt+Down', 'Cmd+Shift+Backspace', 'Ctrl+Tab', 'F5']) { + expect(editsText(accel, 'darwin'), accel).toBe(false) + } + for (const accel of ['Alt+Left', 'Ctrl+PageDown', 'Ctrl+Alt+Right', 'Ctrl+Shift+Delete', 'Alt+2']) { + expect(editsText(accel, 'linux'), accel).toBe(false) + } + }) + + it('counts a printable key with Shift, or macOS’s ⌥, as typing', () => { + expect(editsText('Shift+K', 'darwin')).toBe(true) + expect(editsText('Alt+K', 'darwin')).toBe(true) + expect(editsText('Alt+K', 'linux')).toBe(false) + expect(editsText('Shift+F3', 'linux')).toBe(false) + }) + + it('leaves keys to an input method while it composes, unless ⌘ or Ctrl is held', () => { + expect(actionForInput(press('F5', 'F5', { isComposing: true }), 'linux')).toBeUndefined() + expect(actionForInput(press('F5', 'F5'), 'linux')?.id).toBe('nav.reload') + expect(actionForInput(press('t', 'KeyT', { control: true, isComposing: true }), 'linux')?.id).toBe('tab.new') + }) +}) + +describe('[KS-03] AltGr typing is not a Ctrl+Alt shortcut', () => { + it('tells AltGr characters from the key’s own', () => { + // Polish AltGr+N types ń; German AltGr+Q types @ and AltGr+7 types {. + expect(typedWithAltGr(press('ń', 'KeyN', { control: true, alt: true }), 'win32')).toBe(true) + expect(typedWithAltGr(press('@', 'KeyQ', { control: true, alt: true }), 'linux')).toBe(true) + expect(typedWithAltGr(press('{', 'Digit7', { control: true, alt: true }), 'linux')).toBe(true) + expect(typedWithAltGr(press('n', 'KeyN', { control: true, alt: true }), 'win32')).toBe(false) + expect(typedWithAltGr(press('2', 'Digit2', { control: true, alt: true }), 'linux')).toBe(false) + expect(typedWithAltGr(press('[', 'BracketLeft', { control: true, alt: true }), 'linux')).toBe(false) + expect(typedWithAltGr(press('ArrowLeft', 'ArrowLeft', { control: true, alt: true }), 'linux')).toBe(false) + expect(typedWithAltGr(press('ń', 'KeyN', { meta: true, alt: true }), 'darwin')).toBe(false) + }) + + it('runs a Ctrl+Alt action only outside text fields when the press typed a character', () => { + const altGrN = press('ń', 'KeyN', { control: true, alt: true }) + expect(actionForInput(altGrN, 'win32')).toBeUndefined() + expect(outsideTextActionFor(altGrN, 'win32')?.id).toBe('window.newMini') + const plain = press('n', 'KeyN', { control: true, alt: true }) + expect(actionForInput(plain, 'win32')?.id).toBe('window.newMini') + expect(outsideTextActionFor(plain, 'win32')).toBeUndefined() + }) +}) diff --git a/tests/unit/keymap-remap.test.ts b/tests/unit/keymap-remap.test.ts new file mode 100644 index 00000000..aaf26180 --- /dev/null +++ b/tests/unit/keymap-remap.test.ts @@ -0,0 +1,235 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + bind, + MAX_BINDINGS, + normalizeKeySettings, + normalizePriorities, + normalizeShortcuts, + reset, + setPriority, + unbind, +} from '../../src/main/keys/bindings' +import { + ACTIONS_BY_ID, + actionForAccelerator, + actionForInput, + buildKeymap, + priorityFor, + shortcutsFor, +} from '../../src/shared/actions' +import { keymapVersion, onKeymapChange, setCustomBindings } from '../../src/shared/keymap' +import type { Settings } from '../../src/shared/state' + +const action = (id: string) => { + const found = ACTIONS_BY_ID[id] + if (!found) throw new Error(`no action ${id}`) + return found +} +const cmdS = { key: 's', code: 'KeyS', meta: true, control: false, alt: false, shift: false } +const cmdShiftB = { key: 'b', code: 'KeyB', meta: true, control: false, alt: false, shift: true } + +afterEach(() => setCustomBindings(undefined)) + +describe('[KS-01] one keymap with the user’s bindings', () => { + it('uses the user’s list for an action they changed and the defaults elsewhere', () => { + const keymap = buildKeymap({ 'sidebar.toggle': ['Cmd+Shift+B'] }, 'darwin') + expect(keymap.lists.get('sidebar.toggle')).toEqual(['Cmd+Shift+B']) + expect(keymap.direct.get('Cmd+Shift+B')?.id).toBe('sidebar.toggle') + expect(keymap.direct.get('Cmd+S')).toBeUndefined() + expect(keymap.lists.get('tab.new')).toEqual(['Cmd+T']) + }) + + it('gives a key the user took for another action to that action only', () => { + const keymap = buildKeymap({ 'sidebar.toggle': ['Cmd+D'] }, 'darwin') + expect(keymap.direct.get('Cmd+D')?.id).toBe('sidebar.toggle') + expect(keymap.lists.get('tab.togglePin')).toEqual([]) + }) + + it('never lets a binding take a fixed action’s key (⌘, stays Settings)', () => { + const keymap = buildKeymap({ 'tab.new': ['Cmd+,'], 'tab.recent': ['Cmd+J'] }, 'darwin') + expect(keymap.direct.get('Cmd+,')?.id).toBe('app.settings') + expect(keymap.lists.get('tab.new')).toEqual([]) + // A fixed action keeps its own keys whatever is stored for it. + expect(keymap.lists.get('tab.recent')).toEqual(['Ctrl+Tab']) + }) + + it('keeps an action with no bindings: the command bar and menus still reach it', () => { + const keymap = buildKeymap({ 'sidebar.toggle': [] }, 'linux') + expect(keymap.lists.get('sidebar.toggle')).toEqual([]) + expect(keymap.direct.get('Ctrl+S')).toBeUndefined() + }) + + it('puts a binding that types text with the keys that act only outside text fields (KS-16)', () => { + const keymap = buildKeymap({ 'tab.new': ['Shift+K'] }, 'darwin') + expect(keymap.outsideText.get('Shift+K')?.id).toBe('tab.new') + expect(keymap.direct.get('Shift+K')).toBeUndefined() + // ⌘← on macOS: Back, outside text fields only. + expect(keymap.outsideText.get('Cmd+Left')?.id).toBe('nav.back') + }) + + it('follows the user’s bindings live, and tells listeners', () => { + const heard = vi.fn() + const off = onKeymapChange(heard) + const before = keymapVersion() + expect(actionForInput(cmdS, 'darwin')?.id).toBe('sidebar.toggle') + setCustomBindings({ 'sidebar.toggle': ['Cmd+Shift+B'] }) + expect(keymapVersion()).toBe(before + 1) + expect(heard).toHaveBeenCalledTimes(1) + expect(actionForInput(cmdS, 'darwin')).toBeUndefined() + expect(actionForInput(cmdShiftB, 'darwin')?.id).toBe('sidebar.toggle') + expect(shortcutsFor(action('sidebar.toggle'), 'darwin')).toEqual(['Cmd+Shift+B']) + expect(actionForAccelerator('Shift+Cmd+b', 'darwin')?.id).toBe('sidebar.toggle') + // The same map again is no change. + setCustomBindings({ 'sidebar.toggle': ['Cmd+Shift+B'] }) + expect(heard).toHaveBeenCalledTimes(2) + const same = { 'sidebar.toggle': ['Cmd+Shift+B'] } + setCustomBindings(same) + setCustomBindings(same) + expect(heard).toHaveBeenCalledTimes(3) + off() + setCustomBindings(undefined) + expect(heard).toHaveBeenCalledTimes(3) + expect(actionForInput(cmdS, 'darwin')?.id).toBe('sidebar.toggle') + }) +}) + +describe('[KS-01][ST-08] changing bindings', () => { + it('replaces a binding, or adds another (aliases)', () => { + const replaced = bind(undefined, { actionId: 'sidebar.toggle', accel: 'Cmd+Shift+B', replace: 'Cmd+S' }, 'darwin') + expect(replaced.result).toEqual({ ok: true }) + expect(replaced.custom).toEqual({ 'sidebar.toggle': ['Cmd+Shift+B'] }) + const added = bind(undefined, { actionId: 'sidebar.toggle', accel: 'shift+cmd+b' }, 'darwin') + expect(added.custom).toEqual({ 'sidebar.toggle': ['Cmd+S', 'Cmd+Shift+B'] }) + }) + + it('drops an action’s entry when its bindings are the defaults again', () => { + const { custom } = bind( + { 'sidebar.toggle': ['Cmd+Shift+B'] }, + { actionId: 'sidebar.toggle', accel: 'Cmd+S', replace: 'Cmd+Shift+B' }, + 'darwin', + ) + expect(custom).toEqual({}) + }) + + it('reports a conflict and changes nothing until asked to clear or swap', () => { + const asked = bind(undefined, { actionId: 'sidebar.toggle', accel: 'Cmd+D', replace: 'Cmd+S' }, 'darwin') + expect(asked.result).toEqual({ ok: false, conflict: { actionId: 'tab.togglePin', title: 'Pin / Unpin Tab' } }) + expect(asked.custom).toEqual({}) + + const cleared = bind( + undefined, + { actionId: 'sidebar.toggle', accel: 'Cmd+D', replace: 'Cmd+S', onConflict: 'clear' }, + 'darwin', + ) + expect(cleared.result).toEqual({ ok: true }) + expect(cleared.custom).toEqual({ 'sidebar.toggle': ['Cmd+D'], 'tab.togglePin': [] }) + + const swapped = bind( + undefined, + { actionId: 'sidebar.toggle', accel: 'Cmd+D', replace: 'Cmd+S', onConflict: 'swap' }, + 'darwin', + ) + expect(swapped.custom).toEqual({ 'sidebar.toggle': ['Cmd+D'], 'tab.togglePin': ['Cmd+S'] }) + const keymap = buildKeymap(swapped.custom, 'darwin') + expect(keymap.direct.get('Cmd+S')?.id).toBe('tab.togglePin') + expect(keymap.direct.get('Cmd+D')?.id).toBe('sidebar.toggle') + }) + + it('refuses bindings Orbit can’t use and keys a fixed action keeps', () => { + expect(bind(undefined, { actionId: 'sidebar.toggle', accel: 'B' }, 'darwin').result).toEqual({ + ok: false, + problem: 'A shortcut starts with ⇧, ⌃, ⌥ or ⌘ (F1–F12 can be used on their own).', + }) + expect(bind(undefined, { actionId: 'sidebar.toggle', accel: 'Cmd+,' }, 'darwin').result).toEqual({ + ok: false, + problem: '⌘, is kept for Settings.', + }) + expect(bind(undefined, { actionId: 'app.settings', accel: 'Cmd+Shift+B' }, 'darwin').result).toEqual({ + ok: false, + problem: 'Settings keeps its shortcut.', + }) + expect(bind(undefined, { actionId: 'no.such', accel: 'Cmd+Shift+B' }, 'darwin').result).toMatchObject({ + ok: false, + }) + expect(bind(undefined, { actionId: 'sidebar.toggle', accel: 'F7' }, 'linux').result).toEqual({ ok: true }) + }) + + it(`keeps at most ${MAX_BINDINGS} bindings on an action`, () => { + let custom = {} + for (const n of [1, 2, 3, 4]) custom = bind(custom, { actionId: 'tab.new', accel: `F${n}` }, 'darwin').custom + expect(custom).toEqual({ 'tab.new': ['Cmd+T', 'F1', 'F2', 'F3', 'F4'] }) + const more = bind(custom, { actionId: 'tab.new', accel: 'F6' }, 'darwin') + expect(more.result).toEqual({ ok: false, problem: `An action can have up to ${MAX_BINDINGS} shortcuts.` }) + expect(more.custom).toEqual(custom) + }) + + it('takes a binding off, leaving the action without keys', () => { + expect(unbind(undefined, 'sidebar.toggle', 'Ctrl+S', 'linux')).toEqual({ 'sidebar.toggle': [] }) + expect(unbind(undefined, 'sidebar.toggle', 'Ctrl+Q', 'linux')).toEqual({}) + expect(unbind(undefined, 'app.settings', 'Ctrl+,', 'linux')).toEqual({}) + }) + + it('resets an action, taking its default keys back from the action given them', () => { + const { custom } = bind(undefined, { actionId: 'sidebar.toggle', accel: 'Cmd+D', onConflict: 'clear' }, 'darwin') + expect(custom).toEqual({ 'sidebar.toggle': ['Cmd+S', 'Cmd+D'], 'tab.togglePin': [] }) + expect(reset(custom, 'tab.togglePin', 'darwin')).toEqual({}) + expect(reset(custom, 'sidebar.toggle', 'darwin')).toEqual({ 'tab.togglePin': [] }) + }) +}) + +describe('[KS-01] stored bindings survive a restart only as Orbit can use them', () => { + it('keeps what is valid and drops the rest', () => { + expect(normalizeShortcuts(undefined, 'darwin')).toBeUndefined() + expect(normalizeShortcuts(['Cmd+B'], 'darwin')).toBeUndefined() + expect( + normalizeShortcuts( + { + 'sidebar.toggle': ['shift+cmd+b', 'B', 42, 'Cmd+Q', 'Cmd+Shift+B'], + 'space.goto.2': ['F8'], + 'no.such': ['Cmd+Shift+Y'], + 'app.settings': ['Cmd+Shift+U'], + 'tab.new': 'Cmd+T', + // Taken already by an action earlier in the list. + 'space.rename': ['Cmd+Shift+B'], + 'tab.close': ['Cmd+W'], + }, + 'darwin', + ), + ).toEqual({ 'sidebar.toggle': ['Cmd+Shift+B'], 'space.goto.2': ['F8'] }) + }) + + it('keeps no more than the most bindings an action can have', () => { + const many = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7'] + expect(normalizeShortcuts({ 'tab.new': many }, 'linux')).toEqual({ 'tab.new': many.slice(0, MAX_BINDINGS) }) + }) + + it('cleans settings in place', () => { + const settings = { + shortcuts: { 'sidebar.toggle': ['Ctrl+S'] }, + shortcutPriority: { 'sidebar.toggle': 'orbit', 'tab.new': 'sometimes' }, + } as unknown as Settings + normalizeKeySettings(settings, 'linux') + expect(settings.shortcuts).toBeUndefined() + expect(settings.shortcutPriority).toEqual({ 'sidebar.toggle': 'orbit' }) + }) +}) + +describe('[KS-02] who gets an action’s keys first', () => { + it('has defaults per action and platform', () => { + expect(priorityFor(action('sidebar.toggle'), 'darwin')).toBe('websites') + expect(priorityFor(action('sidebar.toggle'), 'linux')).toBe('websites') + expect(priorityFor(action('tab.copyUrl'), 'darwin')).toBe('orbit') + expect(priorityFor(action('tab.copyUrl'), 'win32')).toBe('websites') + expect(priorityFor(action('tab.new'), 'linux')).toBe('orbit') + }) + + it('stores only a choice that differs from the default', () => { + expect(setPriority(undefined, 'sidebar.toggle', 'orbit', 'linux')).toEqual({ 'sidebar.toggle': 'orbit' }) + expect(setPriority({ 'sidebar.toggle': 'orbit' }, 'sidebar.toggle', 'websites', 'linux')).toEqual({}) + expect(setPriority(undefined, 'app.settings', 'websites', 'linux')).toEqual({}) + expect(priorityFor(action('tab.new'), 'linux', { 'tab.new': 'websites' })).toBe('websites') + expect(normalizePriorities({ 'tab.new': 'orbit', 'tab.close': 'websites', x: 'orbit' }, 'linux')).toEqual({ + 'tab.close': 'websites', + }) + }) +}) From 08c9904d1d7baf7664988336ed403394606fa999 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:17:30 +0000 Subject: [PATCH 03/11] feat(keys): default keys checked against the spec tables; Close Window and F6 (KS-03, KS-05) - Every default binding is checked against the spec's shortcut tables on macOS, Windows and Linux (unit test), with no key bound twice and each one a binding Orbit accepts. - Close Window is an action: Cmd+Shift+W / Ctrl+Shift+W closes the window, or the Mini Window, Settings or onboarding window that has focus; the macOS File menu item runs it. - F6 moves the keyboard between the sidebar and the page. - F12 opens DevTools on macOS too; F1 opens Help on Windows and Linux. - The macOS menu shows bindings that edit text without registering them, so a text field keeps them. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 28 ++--- docs/feature-status.json | 2 + src/main/keys/index.ts | 2 + src/main/keys/window-keys.ts | 25 +++++ src/main/menu.ts | 8 +- src/main/miniwindow/index.ts | 1 + src/shared/actions.ts | 13 ++- src/shared/commands/settings.ts | 9 +- tests/e2e/keys.spec.ts | 60 +++++++++++ tests/unit/keymap-defaults.test.ts | 161 +++++++++++++++++++++++++++++ tests/unit/keymap-remap.test.ts | 2 +- 11 files changed, 292 insertions(+), 19 deletions(-) create mode 100644 src/main/keys/window-keys.ts create mode 100644 tests/unit/keymap-defaults.test.ts diff --git a/FEATURES.md b/FEATURES.md index 3947961a..2360889c 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,12 +4,12 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 201 built (74 partial) · 285 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 201 built (76 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 131 | 161 | -| P1 | 221 | 61 | 113 | +| P0 | 174 | 131 | 164 | +| P1 | 221 | 61 | 116 | | P2 | 80 | 9 | 11 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -348,19 +348,19 @@ Spec: `spec/shortcuts.md` |---|---|---|---|---|---|---| | KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08 | | KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | – | ✅ unit | – | | -| KS-03 | Platform mapping rules | P0 | – | ✅ unit | – | | -| KS-04 | Command bar and address | P0 | ✅ | ✅ e2e | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | -| KS-05 | Tabs and windows | P0 | – | – | – | | -| KS-06 | Switching tabs | P0 | – | – | – | | -| KS-07 | Spaces | P0 | ✅ | ✅ e2e | – | Space shortcuts wired through the action table | +| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08 | +| KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | +| KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | +| KS-06 | Switching tabs | P0 | – | ✅ e2e+unit | – | | +| KS-07 | Spaces | P0 | ✅ | ✅ e2e+unit | – | Space shortcuts wired through the action table | | KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items | -| KS-09 | Split view | P0 | – | – | – | | -| KS-10 | Peek and Mini Window | P1 | – | – | – | | -| KS-11 | Media | P1 | 🟡 partial | ✅ e2e | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | -| KS-12 | Library, history, downloads, settings, extensions, help | P1 | – | ✅ e2e | – | | -| KS-13 | Developer tools | P1 | – | – | – | | +| KS-09 | Split view | P0 | – | ✅ unit | – | | +| KS-10 | Peek and Mini Window | P1 | – | ✅ unit | – | | +| KS-11 | Media | P1 | 🟡 partial | ✅ e2e+unit | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | +| KS-12 | Library, history, downloads, settings, extensions, help | P1 | – | ✅ e2e+unit | – | | +| KS-13 | Developer tools | P1 | – | ✅ unit | – | | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | -| KS-15 | Standard app and window keys | P1 | – | – | – | | +| KS-15 | Standard app and window keys | P1 | – | ✅ unit | – | | | KS-16 | Text-editing keys keep their meaning | P0 | 🟡 partial | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | – | – | – | | | KS-18 | Shortcut hints everywhere | P1 | 🟡 partial | ✅ e2e | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come | diff --git a/docs/feature-status.json b/docs/feature-status.json index 59365038..1929ee39 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -139,6 +139,8 @@ "KS-16": {"built": "partial", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do"}, "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08"}, "KS-18": {"built": "partial", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come"}, + "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08"}, + "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, "FL-14": {"built": "yes", "note": "theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade"}, "FL-16": {"built": "yes", "note": "motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window"}, diff --git a/src/main/keys/index.ts b/src/main/keys/index.ts index 0bd7f08e..383c8e20 100644 --- a/src/main/keys/index.ts +++ b/src/main/keys/index.ts @@ -4,6 +4,7 @@ import type { MainContext } from '../context' import { installMenu } from '../menu' import { bind, reset, setPriority, unbind } from './bindings' import { keyRouter } from './router' +import { registerWindowKeys } from './window-keys' /** * The keymap in main (KS-01): the user's bindings follow `settings.shortcuts`, the menu bar is @@ -64,6 +65,7 @@ export function registerKeys(ctx: MainContext): void { if (!text(actionId)) return savePriorities(setPriority(store.state.settings.shortcutPriority, actionId, priority, platform)) }) + registerWindowKeys(ctx) keyRouter.init(ctx) commands.register('keys.record', ({ on }, c) => keyRouter.setRecording(on ? c.windowId : undefined)) } diff --git a/src/main/keys/window-keys.ts b/src/main/keys/window-keys.ts new file mode 100644 index 00000000..40626372 --- /dev/null +++ b/src/main/keys/window-keys.ts @@ -0,0 +1,25 @@ +import { webContents } from 'electron' +import type { MainContext } from '../context' + +/** + * Window keys from the keymap's tables that no feature had yet (KS-05): Close Window + * (⌘⇧W / Ctrl+Shift+W) and F6, which moves the keyboard between the sidebar and the page (SB-22). + * Settings, onboarding and Mini Windows close themselves through their own action interceptors. + */ +export function registerWindowKeys(ctx: MainContext): void { + const { actions, windows } = ctx + actions.register('window.close', (c) => { + const windowId = c.windowId ?? windows.focusedWindowId() + const win = windowId ? windows.get(windowId) : undefined + if (win && !win.isDestroyed()) win.close() + // A page's pop-up or detached DevTools in front. + else windows.closeFocusedForeign() + }) + actions.register('focus.cycle', (c) => { + const windowId = c.windowId ?? windows.focusedWindowId() + const win = windowId ? windows.get(windowId) : undefined + if (!windowId || !win || win.isDestroyed()) return + if (webContents.getFocusedWebContents() === win.webContents) windows.focusActiveTab(windowId) + else void actions.run('sidebar.focus', { windowId }) + }) +} diff --git a/src/main/menu.ts b/src/main/menu.ts index f76b9b9d..35450819 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,5 +1,6 @@ import { app, Menu, type MenuItemConstructorOptions } from 'electron' import { ACTIONS_BY_ID, shortcutsFor, toElectronAccelerator } from '../shared/actions' +import { editsText } from '../shared/keymap' import { keepForMenu } from './archive/menus' import { recentlyClosedItems } from './archive/recently-closed' import type { MainContext } from './context' @@ -22,10 +23,13 @@ export function installMenu(ctx: MainContext): void { return { label: label ?? action?.title ?? id, accelerator: accel ? toElectronAccelerator(accel) : undefined, + // A binding that types or edits text (KS-16) is only shown: the router runs it outside text + // fields, and the menu must not take it from one. + ...(accel && editsText(accel, 'darwin') ? { registerAccelerator: false } : {}), click: () => { const windowId = ctx.windows.focusedWindowId() // With a page's popup or detached DevTools focused, Close Tab closes that window instead. - if (!windowId && id === 'tab.close' && ctx.windows.closeFocusedForeign()) return + if (!windowId && (id === 'tab.close' || id === 'window.close') && ctx.windows.closeFocusedForeign()) return void ctx.actions.run(id, { windowId }) }, } @@ -59,7 +63,7 @@ export function installMenu(ctx: MainContext): void { item('window.newPrivate'), { type: 'separator' }, item('tab.close'), - { role: 'close', label: 'Close Window', accelerator: 'Command+Shift+W' }, + item('window.close'), item('tab.reopenClosed'), // The last ~10 closed tabs (archive lane, AR-09); rebuilt as tabs close and come back. { label: 'Recently Closed', submenu: recentlyClosedItems(ctx) }, diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index ad4f051d..4fcb7d92 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -696,6 +696,7 @@ export function registerMiniWindow(ctx: MainContext): void { if (!mini) return false switch (id) { case 'tab.close': + case 'window.close': closeWindow(mini) return true case 'peek.expand': diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 39a37132..40ed95ed 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -282,7 +282,7 @@ export const ACTIONS: ActionDef[] = [ id: 'devtools.toggle', title: 'Developer Tools', category: 'Developer', - mac: ['Cmd+Alt+I'], + mac: ['Cmd+Alt+I', 'F12'], other: ['Ctrl+Shift+I', 'F12'], keywords: ['inspect', 'console'], }, @@ -298,6 +298,17 @@ export const ACTIONS: ActionDef[] = [ preferWebsites: true, }, { id: 'window.new', title: 'New Window', category: 'Window', mac: ['Cmd+N'], other: ['Ctrl+N'] }, + // Alt+F4 closes the window too, as the system's key (KS-05). + { id: 'window.close', title: 'Close Window', category: 'Window', mac: ['Cmd+Shift+W'], other: ['Ctrl+Shift+W'] }, + { + // Keyboard focus between the sidebar and the page (KS-05, SB-22). + id: 'focus.cycle', + title: 'Move Focus Between Sidebar and Page', + category: 'Sidebar', + mac: ['F6'], + other: ['F6'], + keywords: ['keyboard', 'accessibility', 'focus sidebar', 'focus page'], + }, { id: 'window.newMini', title: 'New Mini Window', diff --git a/src/shared/commands/settings.ts b/src/shared/commands/settings.ts index 5fee0b59..277f4901 100644 --- a/src/shared/commands/settings.ts +++ b/src/shared/commands/settings.ts @@ -138,7 +138,14 @@ export const SETTINGS_LANE_ACTIONS: ActionDef[] = [ // Runs the first-run flow again over the open windows, keeping all data (OB-14). { id: 'onboarding.reset', title: 'Reset Onboarding', category: 'App', keywords: ['welcome', 'first run', 'setup'] }, // Help and feedback (OB-13): the project's public pages, in a tab. Orbit has no support server. - { id: 'help.open', title: 'Help', category: 'App', keywords: ['documentation', 'user guide', 'support', 'readme'] }, + { + id: 'help.open', + title: 'Help', + category: 'App', + // Windows and Linux: F1 (KS-15); macOS has ⌘? for the Help menu. + other: ['F1'], + keywords: ['documentation', 'user guide', 'support', 'readme'], + }, { id: 'help.reportBug', title: 'Report a Bug', diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts index 9ef0b4e9..4c50ebce 100644 --- a/tests/e2e/keys.spec.ts +++ b/tests/e2e/keys.spec.ts @@ -201,3 +201,63 @@ test('[KS-01] an action keeps several bindings, or none and the command bar stil await overlay.keyboard.press('Enter') await expect.poll(sidebarVisible).toBe(false) }) + +// ---------- Platform mapping and the window keys (KS-03, KS-05) ---------- + +/** Presses a key wherever the keyboard is (the focused web contents), as a person would. */ +function pressAnywhere(keyCode: string, modifiers: ('shift' | 'control' | 'alt' | 'meta')[] = []): Promise { + return orbit.app.evaluate( + ({ webContents }, [code, mods]) => { + const contents = webContents.getFocusedWebContents() + if (!contents) return '(nothing)' + contents.sendInputEvent({ type: 'keyDown', keyCode: code, modifiers: mods }) + contents.sendInputEvent({ type: 'keyUp', keyCode: code, modifiers: mods }) + return contents.getURL() + }, + [keyCode, modifiers] as const, + ) +} + +/** The URL of the web contents that has the keyboard. */ +function focusedUrl(): Promise { + return orbit.app.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL()) +} + +test('[KS-03][KS-06][KS-07] Linux and Windows: Alt+2 switches to Space 2 and Ctrl+2 selects the second sidebar item (macOS: ⌃2 and ⌘2)', async () => { + const { app, shell } = orbit + const space = await activeSpace(app) + const second = (await run(app, 'tabs.create', { url: fx.url('/page/Second'), activate: false })) as string + const first = (await run(app, 'tabs.create', { url: fx.url('/page/First'), activate: false })) as string + expect((await state(app)).spaces[space]?.today.slice(0, 2)).toEqual([first, second]) + const other = await createSpace(app, { name: 'Other', activate: false }) + await shell.keyboard.press(`${MOD}+2`) + await expect + .poll(async () => { + const win = Object.values((await state(app)).windows)[0] + return win?.activeTab[space] + }) + .toBe(second) + await shell.keyboard.press(isMac ? 'Control+2' : 'Alt+2') + await expect.poll(() => activeSpace(app)).toBe(other) +}) + +test('[KS-05] ⌘⇧W / Ctrl+Shift+W closes the window, and F6 moves the keyboard between the page and the sidebar', async () => { + const { app } = orbit + const id = await openTab(app, fx.url('/page/Focus')) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + await cmd(app, 'tabs.focusPage', {}) + await expect.poll(focusedUrl).toBe(fx.url('/page/Focus')) + expect(await pressAnywhere('F6')).toBe(fx.url('/page/Focus')) + await expect.poll(focusedUrl).toContain('view=shell') + await pressAnywhere('F6') + await expect.poll(focusedUrl).toBe(fx.url('/page/Focus')) + + const firstWindow = Object.keys((await state(app)).windows)[0] + await action(app, 'window.new') + await expect.poll(async () => Object.keys((await state(app)).windows).length).toBe(2) + // The window closes on the key going down: the page is gone before the key comes up. + await orbit.shell.keyboard.press(`${MOD}+Shift+W`).catch(() => {}) + await expect + .poll(async () => Object.keys((await state(app)).windows)) + .toEqual([expect.not.stringMatching(`^${firstWindow}$`)]) +}) diff --git a/tests/unit/keymap-defaults.test.ts b/tests/unit/keymap-defaults.test.ts new file mode 100644 index 00000000..ec018ce4 --- /dev/null +++ b/tests/unit/keymap-defaults.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, ACTIONS_BY_ID, actionForInput, defaultShortcutsFor } from '../../src/shared/actions' +import { bindingProblem, canonicalAccelerator, type KeyInput } from '../../src/shared/keymap' + +/** + * The default keymap against the shortcut tables of the spec (KS-03 audit): every row Orbit binds, + * on macOS and on Windows/Linux. Rows for features no lane has built yet (blank window, top + * toolbar, save page, full screen keys, reader mode, developer mode, notes, canvases, extensions) + * are left out; so are keys that belong to an open overlay (KS-04's Esc, Tab, arrows). + */ +const TABLE: [id: string, mac: string[], other: string[]][] = [ + // KS-04 + ['tab.new', ['Cmd+T'], ['Ctrl+T']], + ['url.edit', ['Cmd+L'], ['Ctrl+L', 'Alt+D']], + ['tab.pasteAsNewTab', ['Cmd+Alt+V'], ['Ctrl+Alt+V']], + // KS-05 + ['window.new', ['Cmd+N'], ['Ctrl+N']], + ['window.newPrivate', ['Cmd+Shift+N'], ['Ctrl+Shift+N']], + ['window.newMini', ['Cmd+Alt+N'], ['Ctrl+Alt+N']], + ['tab.close', ['Cmd+W'], ['Ctrl+W', 'Ctrl+F4']], + ['window.close', ['Cmd+Shift+W'], ['Ctrl+Shift+W']], + ['tab.reopenClosed', ['Cmd+Shift+T'], ['Ctrl+Shift+T']], + ['tab.togglePin', ['Cmd+D'], ['Ctrl+D']], + ['tab.copyUrl', ['Cmd+Shift+C'], ['Ctrl+Shift+C']], + ['tab.copyUrlMarkdown', ['Cmd+Alt+Shift+C'], ['Ctrl+Alt+Shift+C']], + ['sidebar.toggle', ['Cmd+S'], ['Ctrl+S']], + ['tabs.clearToday', ['Cmd+Shift+K'], ['Ctrl+Shift+K']], + ['sidebar.focus', ['Cmd+Shift+E'], ['Ctrl+Shift+E']], + ['focus.cycle', ['F6'], ['F6']], + ['tab.duplicate', [], []], + ['tab.resetToPinned', [], []], + // KS-06 + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n): [string, string[], string[]] => [ + `tab.goto.${n}`, + [`Cmd+${n}`], + [`Ctrl+${n}`], + ]), + ['tab.next', ['Cmd+Alt+Down', 'Cmd+Shift+]'], ['Ctrl+Alt+Down', 'Ctrl+PageDown']], + ['tab.previous', ['Cmd+Alt+Up', 'Cmd+Shift+['], ['Ctrl+Alt+Up', 'Ctrl+PageUp']], + ['tab.recent', ['Ctrl+Tab'], ['Ctrl+Tab']], + ['tab.recentBack', ['Ctrl+Shift+Tab', 'Ctrl+`'], ['Ctrl+Shift+Tab']], + // KS-07: ⌃1–9 on macOS, Alt+1–9 elsewhere (Ctrl+1–9 is tabs). + ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n): [string, string[], string[]] => [ + `space.goto.${n}`, + [`Ctrl+${n}`], + [`Alt+${n}`], + ]), + ['space.next', ['Cmd+Alt+Right'], ['Ctrl+Alt+Right']], + ['space.previous', ['Cmd+Alt+Left'], ['Ctrl+Alt+Left']], + ['space.new', [], []], + // KS-08 (⌘← and ⌘→ act outside text fields only, KS-16) + ['nav.back', ['Cmd+[', 'Cmd+Left'], ['Alt+Left']], + ['nav.forward', ['Cmd+]', 'Cmd+Right'], ['Alt+Right']], + ['nav.reload', ['Cmd+R'], ['Ctrl+R', 'F5']], + ['nav.hardReload', ['Cmd+Shift+R'], ['Ctrl+Shift+R', 'Shift+F5', 'Ctrl+F5']], + ['nav.stop', ['Cmd+.'], []], + ['page.find', ['Cmd+F'], ['Ctrl+F']], + ['page.findNext', ['Cmd+G'], ['F3', 'Ctrl+G']], + ['page.findPrevious', ['Cmd+Shift+G'], ['Shift+F3', 'Ctrl+Shift+G']], + ['page.jumpToSelection', ['Cmd+J'], []], + // Ctrl+Shift+= adds a split pane on Windows/Linux (KS-09), so Ctrl++ is not zoom there. + ['page.zoomIn', ['Cmd+=', 'Cmd+Shift+='], ['Ctrl+=']], + ['page.zoomOut', ['Cmd+-'], ['Ctrl+-']], + ['page.zoomReset', ['Cmd+0'], ['Ctrl+0']], + ['page.print', ['Cmd+P'], ['Ctrl+P']], + ['page.viewSource', ['Cmd+Alt+U'], ['Ctrl+U']], + ['site.controls', [], []], + // KS-09 + ['split.add', ['Ctrl+Shift+='], ['Ctrl+Shift+=']], + ['split.close', ['Ctrl+Shift+-'], ['Ctrl+Shift+-']], + ...[1, 2, 3, 4].map((n): [string, string[], string[]] => [ + `split.focus.${n}`, + [`Ctrl+Shift+${n}`], + [`Ctrl+Shift+${n}`], + ]), + ['split.focusNext', ['Ctrl+Shift+]'], ['Ctrl+Shift+]']], + ['split.focusPrevious', ['Ctrl+Shift+['], ['Ctrl+Shift+[']], + ['split.addRight', [], []], + // KS-10 + ['peek.expand', ['Cmd+O'], ['Ctrl+O']], + ['mini.chooseSpace', ['Cmd+Alt+O', 'Cmd+Shift+O'], ['Ctrl+Alt+O']], + // KS-11 + ['tab.toggleMute', [], []], + // KS-12 + ['app.history', ['Cmd+Y'], ['Ctrl+H']], + ['app.library', ['Cmd+Shift+L'], []], + ['app.archive', [], []], + ['app.settings', ['Cmd+,'], ['Ctrl+,']], + ['settings.clearBrowsingData', ['Cmd+Shift+Backspace'], ['Ctrl+Shift+Delete']], + // KS-13 + ['devtools.toggle', ['Cmd+Alt+I', 'F12'], ['Ctrl+Shift+I', 'F12']], + // KS-15: F1 opens help on Windows and Linux; macOS's ⌘? is the system's Help menu search. + ['help.open', [], ['F1']], +] + +const canonical = (list: readonly string[]) => list.map((a) => canonicalAccelerator(a) ?? `(invalid ${a})`) + +describe('[KS-03] the default keymap follows the spec tables on each platform', () => { + it.each(TABLE)('%s', (id, mac, other) => { + const action = ACTIONS_BY_ID[id] + expect(action, id).toBeDefined() + if (!action) return + expect(canonical(defaultShortcutsFor(action, 'darwin'))).toEqual(canonical(mac)) + expect(canonical(defaultShortcutsFor(action, 'linux'))).toEqual(canonical(other)) + expect(canonical(defaultShortcutsFor(action, 'win32'))).toEqual(canonical(other)) + }) + + it('binds each key once per platform, each a binding Orbit accepts', () => { + for (const platform of ['darwin', 'linux', 'win32']) { + const seen = new Map() + for (const action of ACTIONS) { + for (const accel of defaultShortcutsFor(action, platform)) { + const key = canonicalAccelerator(accel) + expect(key, `${action.id}: ${accel}`).toBeDefined() + expect(bindingProblem(accel, platform), `${action.id}: ${accel} on ${platform}`).toBeUndefined() + expect( + seen.get(key as string), + `${accel}: ${seen.get(key as string)} and ${action.id} on ${platform}`, + ).toBeUndefined() + seen.set(key as string, action.id) + } + } + } + }) + + const press = (key: string, code: string, mods: Partial): KeyInput => ({ + key, + code, + meta: false, + control: false, + alt: false, + shift: false, + ...mods, + }) + + it('Linux: Alt+2 is Space 2 and Ctrl+2 the second sidebar item; macOS: ⌃2 and ⌘2', () => { + expect(actionForInput(press('2', 'Digit2', { alt: true }), 'linux')?.id).toBe('space.goto.2') + expect(actionForInput(press('2', 'Digit2', { control: true }), 'linux')?.id).toBe('tab.goto.2') + expect(actionForInput(press('2', 'Digit2', { control: true }), 'darwin')?.id).toBe('space.goto.2') + expect(actionForInput(press('2', 'Digit2', { meta: true }), 'darwin')?.id).toBe('tab.goto.2') + }) + + it('Windows/Linux exceptions: Ctrl+H history, Alt+←/→, Alt+D, F5, F3, Ctrl+F4', () => { + const linux = (input: KeyInput) => actionForInput(input, 'win32')?.id + expect(linux(press('h', 'KeyH', { control: true }))).toBe('app.history') + expect(linux(press('ArrowLeft', 'ArrowLeft', { alt: true }))).toBe('nav.back') + expect(linux(press('ArrowRight', 'ArrowRight', { alt: true }))).toBe('nav.forward') + expect(linux(press('d', 'KeyD', { alt: true }))).toBe('url.edit') + expect(linux(press('F5', 'F5', {}))).toBe('nav.reload') + expect(linux(press('F3', 'F3', {}))).toBe('page.findNext') + expect(linux(press('F4', 'F4', { control: true }))).toBe('tab.close') + }) + + it('shortcuts that use ⌃ on macOS use Ctrl elsewhere too (⌃Tab, the split keys)', () => { + for (const id of ['tab.recent', 'split.add', 'split.close', 'split.focus.1', 'split.focusNext']) { + const action = ACTIONS_BY_ID[id] + if (!action) throw new Error(id) + expect(defaultShortcutsFor(action, 'darwin')[0], id).toBe(defaultShortcutsFor(action, 'linux')[0]) + } + }) +}) diff --git a/tests/unit/keymap-remap.test.ts b/tests/unit/keymap-remap.test.ts index aaf26180..dffa07a2 100644 --- a/tests/unit/keymap-remap.test.ts +++ b/tests/unit/keymap-remap.test.ts @@ -158,7 +158,7 @@ describe('[KS-01][ST-08] changing bindings', () => { let custom = {} for (const n of [1, 2, 3, 4]) custom = bind(custom, { actionId: 'tab.new', accel: `F${n}` }, 'darwin').custom expect(custom).toEqual({ 'tab.new': ['Cmd+T', 'F1', 'F2', 'F3', 'F4'] }) - const more = bind(custom, { actionId: 'tab.new', accel: 'F6' }, 'darwin') + const more = bind(custom, { actionId: 'tab.new', accel: 'F9' }, 'darwin') expect(more.result).toEqual({ ok: false, problem: `An action can have up to ${MAX_BINDINGS} shortcuts.` }) expect(more.custom).toEqual(custom) }) From 7174f11a2cd2a3c2428c3950a046de0b30f0f5d0 Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 15:36:58 +0000 Subject: [PATCH 04/11] feat(keys): pages that use Orbit's keys get them first where the action prefers websites (KS-02, KS-16) - before-input-event leaves the press of an action that prefers websites to a tab's page; an isolated-world watcher reports whether the page called preventDefault. Left alone, the action runs; used, a toast offers the action and a second press within a second runs it. Held-key repeats stay the page's. - Cmd+S / Ctrl+S (Toggle Sidebar) prefers websites everywhere; Ctrl+Shift+C (Copy URL) on Windows and Linux (NV-17). Settings' per-action priority wins. - In a page text field, Cmd+[ / Cmd+] (Alt+Left / Alt+Right) go to the page first and go back or forward only when the page leaves them alone (KS-16). - The macOS menu runs a let-through key's action once. - e2e: fixture pages that use and ignore the keys, priority set to Orbit, Copy URL, the text-field rule, and Cmd+Z undoing typing in a field. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 14 +-- docs/feature-status.json | 7 +- src/main/keys/page-keys.ts | 105 ++++++++++++++++++ src/main/keys/router.ts | 138 ++++++++++++++++++++++- src/main/menu.ts | 3 + src/main/windows.ts | 4 + src/shared/actions.ts | 9 ++ tests/e2e/keys.spec.ts | 187 +++++++++++++++++++++++++++++++- tests/unit/keymap-remap.test.ts | 10 ++ 9 files changed, 461 insertions(+), 16 deletions(-) create mode 100644 src/main/keys/page-keys.ts diff --git a/FEATURES.md b/FEATURES.md index 2360889c..484ee8a8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,11 +4,11 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 201 built (76 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 203 built (75 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 131 | 164 | +| P0 | 174 | 133 | 164 | | P1 | 221 | 61 | 116 | | P2 | 80 | 9 | 11 | @@ -30,7 +30,7 @@ Spec: `spec/sidebar.md` | SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | 🟡 partial | ✅ e2e | – | switcher, + menu and the Library button (opens the Library); the recent-files hover popover belongs to the downloads lane | | SB-10 | Transient trays above the bottom bar | P1 | 🟡 partial | ✅ e2e | – | media tray and call controls (media lane), above the bottom bar, pushing the list up; download progress and the update banner belong to other lanes | -| SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it | +| SB-11 | Show / hide the sidebar | P0 | ✅ | ✅ e2e | – | Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it; a page that uses Cmd+S / Ctrl+S itself gets the first press, and a second press within a second or the toast's button toggles the sidebar (KS-02) | | SB-12 | Hover to reveal the hidden sidebar | P0 | ✅ | ✅ e2e | – | left 6 px edge brings a floating card in its own view over the page, which never moves; main decides whether the pointer moved (not from where it was when the sidebar hid, so Cmd+S at the edge doesn't) and reads it for 300 ms after it leaves a page or the shell, so a flick to the edge (or onto the Linux resize border) brings the card; 0 ms dwell; stays while hovered (16 px band), while a field in it is edited or the command bar is open; goes 400 ms after the pointer moves on, at once on a click into the page (the click reaches the page); not over full-screen video; stays out while the pointer is on another window over it, until it comes back (review A-F1) | | SB-13 | Feedback when things happen while the sidebar is hidden | P1 | – | – | – | | | SB-14 | Resize the sidebar width | P0 | ✅ | ✅ e2e | – | drag the edge 1:1 with a live width, clamped 180-420 with no drift; double-click resets to 248; 40 px past the minimum hides (width kept); saved per window | @@ -244,7 +244,7 @@ Spec: `spec/navigation.md` | NV-14 | Crashed or unresponsive tab | P1 | – | ✅ e2e | – | | | NV-15 | Permission prompts | P0 | ✅ | ✅ e2e+unit | – | prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13 | | NV-16 | Site controls panel | P0 | 🟡 partial | ✅ e2e+unit | – | button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features | -| NV-17 | Copy the current URL | P0 | 🟡 partial | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02 | +| NV-17 | Copy the current URL | P0 | ✅ | ✅ e2e | – | copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02) | | NV-18 | Links from other apps (Orbit as the default browser) | P0 | ✅ | ✅ e2e | – | summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05 | | NV-19 | Links that open other apps (external protocols) | P1 | – | – | – | | | NV-20 | Full screen | P1 | – | ✅ e2e | – | | @@ -347,7 +347,7 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08 | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | – | ✅ unit | – | | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | | KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08 | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | | KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | @@ -361,7 +361,7 @@ Spec: `spec/shortcuts.md` | KS-13 | Developer tools | P1 | – | ✅ unit | – | | | KS-14 | Canvases, notes, capture and Tweaks | P2 | – | – | – | | | KS-15 | Standard app and window keys | P1 | – | ✅ unit | – | | -| KS-16 | Text-editing keys keep their meaning | P0 | 🟡 partial | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do | +| KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) from a page's text field go back or forward only when the page leaves them alone (a press into a frame Orbit can't see into stays the page's); ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | – | – | – | | | KS-18 | Shortcut hints everywhere | P1 | 🟡 partial | ✅ e2e | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come | | KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e | – | command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes | @@ -542,7 +542,7 @@ Spec: `spec/settings.md` | ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | | ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | -| ST-08 | Shortcuts section (remapping) | P1 | – | ✅ unit | – | | +| ST-08 | Shortcuts section (remapping) | P1 | – | ✅ e2e+unit | – | | | ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | diff --git a/docs/feature-status.json b/docs/feature-status.json index 1929ee39..2e05ab6a 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -7,7 +7,7 @@ "SB-06": {"built": "yes", "note": "title row: inline rename, hover profile badge and … menu (pinned caret is PN-09)"}, "SB-07": {"built": "yes", "note": "+ New Tab row with press animation opens the command bar"}, "SB-09": {"built": "partial", "note": "switcher, + menu and the Library button (opens the Library); the recent-files hover popover belongs to the downloads lane"}, - "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it"}, + "SB-11": {"built": "yes", "note": "Cmd+S / Ctrl+S, toggle button, View menu and command bar; the sidebar slides out as the card widens (one curve, reversible mid-way); the page views, which can't move in step, keep out of its way while it slides (where they end as it shows, where they were as it hides; the page colour fills the rest); back at the same width, scroll and selected tab; per window, persisted; new windows show it; a page that uses Cmd+S / Ctrl+S itself gets the first press, and a second press within a second or the toast's button toggles the sidebar (KS-02)"}, "SB-12": {"built": "yes", "note": "left 6 px edge brings a floating card in its own view over the page, which never moves; main decides whether the pointer moved (not from where it was when the sidebar hid, so Cmd+S at the edge doesn't) and reads it for 300 ms after it leaves a page or the shell, so a flick to the edge (or onto the Linux resize border) brings the card; 0 ms dwell; stays while hovered (16 px band), while a field in it is edited or the command bar is open; goes 400 ms after the pointer moves on, at once on a click into the page (the click reaches the page); not over full-screen video; stays out while the pointer is on another window over it, until it comes back (review A-F1)"}, "SB-14": {"built": "yes", "note": "drag the edge 1:1 with a live width, clamped 180-420 with no drift; double-click resets to 248; 40 px past the minimum hides (width kept); saved per window"}, "SB-19": {"built": "yes", "note": "8 px margins (6 px with the sidebar hidden), 10 px corners on every page and pane while loading, hidden or floating; fine edge and shadow; native and video full screen drop the frame; the card and the page view take the page background colour so resizes show no white"}, @@ -136,8 +136,9 @@ "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, "KS-19": {"built": "partial", "note": "command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes"}, - "KS-16": {"built": "partial", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; the full text-editing and IME audit is still to do"}, + "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) from a page's text field go back or forward only when the page leaves them alone (a press into a frame Orbit can't see into stays the page's); ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router)"}, "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, "KS-18": {"built": "partial", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come"}, "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08"}, "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, @@ -194,7 +195,7 @@ "NV-15": {"built": "yes", "note": "prompt under the address display, as wide as it (over the page's top-left while the sidebar is hidden), for the shown tab or else a split view pane that waits: camera, mic, location, notifications, clipboard, MIDI, idle, windows, fonts, local network, serial/USB/HID choosers, screen share, file access; Allow while visiting / Allow this time / Never allow, or Block / Allow; a press answers only once the prompt has shown for 500 ms and 500 ms after the press before; Esc dismisses; kept per profile and site (private in memory); a frame from another site can't ask for notifications or use the page's; pages read undecided as default/prompt (a PermissionStatus change event doesn't fire for a decision made in Orbit, and the patched getters and requestPermission show their source to toString); a notification click brings its tab forward (page notifications, not service-worker ones; only from a frame allowed notifications, once per 500 ms); Peek, Mini Window and pop-ups ask in a dialog over their own window (Allow, Block, Not Now; Esc keeps nothing; the same request asks once); the recording indicators wait on TB-15; toolbar anchor waits on SB-17, Site settings is ST-13"}, "NV-16": {"built": "partial", "note": "button at the address's right end and the Site Controls action (never shows the hidden sidebar): host and connection, switches for permissions the site used or asked for (pop-ups too), devices, zoom with reset, Reset permissions, Clear cookies and site data, Reload hint; Esc or a click outside closes; auto PiP, Peek, Developer mode, Tweaks, extensions, capture and the all-sites link wait on their features"}, "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, - "NV-17": {"built": "partial", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; the Win/Linux page-first offer waits on KS-02"}, + "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, "FL-23": {"built": "yes", "note": "after 150 ms, a 48x4 theme-accent pill sweeping on a 1.2 s cycle (a pulse with reduced motion); on load end it fills (80 ms) and fades (150 ms), gone 180 ms after; never moves page content"}, diff --git a/src/main/keys/page-keys.ts b/src/main/keys/page-keys.ts new file mode 100644 index 00000000..9c4334ab --- /dev/null +++ b/src/main/keys/page-keys.ts @@ -0,0 +1,105 @@ +import type { WebContents } from 'electron' + +/** Isolated world for the keys lane's page script (KS-02, KS-16): the page can't see or change it. */ +export const KEYS_WORLD = 1020 + +/** + * Notes, for each trusted key press in the page's main frame, whether the page called + * preventDefault on it once every one of its own handlers has run, and whether keyboard focus was + * in a text field. `__orbitKeys.verdict` answers for one press. Lives in the page's document, so + * it is installed again for each new document. + */ +const WATCH = `(() => { + if (window.__orbitKeys) return true + const seen = [] + let waiters = [] + const editable = () => { + const el = document.activeElement + if (!el || el === document.body || el === document.documentElement) return false + if (el.isContentEditable) return true + const tag = el.tagName + if (tag === 'TEXTAREA' || tag === 'SELECT') return true + if (tag !== 'INPUT') return false + const type = (el.getAttribute('type') || 'text').toLowerCase() + return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit'].includes(type) + } + addEventListener('keydown', (e) => { + if (!e.isTrusted) return + const sig = [e.metaKey, e.ctrlKey, e.altKey, e.shiftKey, e.code].join(',') + const at = Date.now() + const inField = editable() + // After the whole dispatch: the page's handlers have all had the event. + setTimeout(() => { + seen.push({ sig, at, inField, prevented: e.defaultPrevented }) + if (seen.length > 16) seen.shift() + const woken = waiters + waiters = [] + for (const wake of woken) wake() + }, 0) + }, true) + window.__orbitKeys = { + verdict(sig, since, wait) { + return new Promise((resolve) => { + let done = false + const answer = () => { + const r = seen.find((x) => x.sig === sig && x.at >= since) + if (!r) return false + done = true + resolve({ seen: true, prevented: r.prevented, inField: r.inField }) + return true + } + if (answer()) return + const wake = () => { + if (!done && !answer()) waiters.push(wake) + } + waiters.push(wake) + setTimeout(() => { + if (done) return + done = true + resolve({ seen: false }) + }, wait) + }) + }, + } + return true +})()` + +/** Puts the watcher in a tab's page, now and in each document it loads (KS-02). */ +export function watchPageKeys(contents: WebContents): void { + const install = () => { + if (contents.isDestroyed()) return + contents.executeJavaScriptInIsolatedWorld(KEYS_WORLD, [{ code: WATCH }]).catch(() => {}) + } + contents.on('dom-ready', install) +} + +/** What the page did with a key press. */ +export type PageVerdict = + /** The press reached the page's main frame: whether it called preventDefault, and whether focus was in a text field. */ + | { seen: true; prevented: boolean; inField: boolean } + /** Not within the wait: the page stopped it before any handler, or focus is in a frame. */ + | { seen: false } + /** The page has no watcher (still loading, or a page Orbit can't script). */ + | undefined + +/** How long to wait for the page's answer before taking it that the page kept the key. */ +export const VERDICT_WAIT_MS = 250 + +/** + * What the page did with the key press `input`, pressed at `pressedAt` (main's clock, which the + * page shares). Asked as the press is let through to the page. + */ +export async function pageVerdict( + contents: WebContents, + input: Electron.Input, + pressedAt: number, +): Promise { + const sig = [input.meta, input.control, input.alt, input.shift, input.code].join(',') + const code = `window.__orbitKeys ? window.__orbitKeys.verdict(${JSON.stringify(sig)}, ${pressedAt - 10}, ${VERDICT_WAIT_MS}) : null` + try { + const verdict = (await contents.executeJavaScriptInIsolatedWorld(KEYS_WORLD, [{ code }])) as PageVerdict | null + return verdict ?? undefined + } catch { + return undefined + } +} diff --git a/src/main/keys/router.ts b/src/main/keys/router.ts index 8d674f97..5642c729 100644 --- a/src/main/keys/router.ts +++ b/src/main/keys/router.ts @@ -1,21 +1,53 @@ import { type WebContents, webContents } from 'electron' +import { type ActionDef, formatAccelerator, priorityFor } from '../../shared/actions' import type { RecordedKey } from '../../shared/commands/keys' import { acceleratorFromInput, modifiersOf } from '../../shared/keymap' import type { WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { pageVerdict, watchPageKeys } from './page-keys' + +/** A second press of the same keys within this long after the page took the first runs the action (KS-02). */ +export const AGAIN_MS = 1000 + +/** How long after the router ran an action the page left alone the macOS menu doesn't run it again. */ +const MENU_DOUBLE_MS = 500 + +/** A key press left to a page first, waiting for what the page did with it. */ +interface FirstPress { + actionId: string + /** Until when another press of the same keys runs the action: set once the page's answer is in. */ + until: number + /** The macOS menu ran the action: the page left the key alone. */ + menuRan?: boolean +} /** - * The keys lane's part of the key router (src/main/windows.ts `attachShortcuts` calls it): a - * window where Settings → Shortcuts is recording a new binding gets its key presses reported - * instead of acted on (ST-08). + * The keys lane's part of the key router (src/main/windows.ts `attachShortcuts` calls it): + * - a window where Settings → Shortcuts records a new binding gets its key presses reported + * instead of acted on (ST-08); + * - an action that prefers websites leaves the first press to a web page that handles the keys + * itself (KS-02). */ export class KeyRouter { private ctx: MainContext | undefined /** The window whose key presses are being recorded (the Settings window), if any. */ private recordingIn: WindowId | undefined + /** First presses left to pages, by web contents and binding. */ + private firstPresses = new Map() + /** When the router last ran each action because a page left its keys alone. */ + private ranAfterPage = new Map() init(ctx: MainContext): void { this.ctx = ctx + ctx.hooks.tabCreated.push((_tabId, contents) => watchPageKeys(contents)) + // An action run some other way after a page took its keys (the toast's button, the command + // bar): the next press of the keys is a first press again. Watches only; never handles. + ctx.actions.intercept((id) => { + for (const [key, first] of this.firstPresses) { + if (first.actionId === id && first.until !== Number.POSITIVE_INFINITY) this.firstPresses.delete(key) + } + return false + }) } /** Starts (with the window) or stops recording. Closing the window stops it too. */ @@ -62,6 +94,106 @@ export class KeyRouter { if (!contents.isDestroyed()) contents.send('orbit:keys.recorded', recorded) return true } + + /** + * Keys a web page may use (KS-02, KS-16). Returns true when the press goes to the page: the + * caller lets it through and runs nothing now. Applies to a tab's page and: + * - an action that prefers websites: if the page leaves the press alone, the action runs then. + * If the page used it, a toast offers the action, and pressing the same keys again within + * about a second runs it: for that press this returns false, and the caller runs the action + * at once and keeps the key from the page. A held key's repeats stay the page's. + * - an action that prefers Orbit but leaves its keys to text fields (Back and Forward's ⌘[ ⌘]): + * the action runs unless focus was in a text field and the page used the press, or the press + * went into a frame Orbit can't see into. + */ + leaveToPage( + contents: WebContents, + input: Electron.Input, + action: ActionDef, + windowOf: () => WindowId | undefined, + ): boolean { + const ctx = this.ctx + if (!ctx?.tabs.tabIdFor(contents)) return false + const priority = priorityFor(action, process.platform, ctx.store.state.settings.shortcutPriority) + if (priority === 'orbit' && !action.pageFirstInFields) return false + const key = `${contents.id} ${acceleratorFromInput(input) ?? ''}` + const windowId = windowOf() + // Until the page's answer is in, the macOS menu may run the action instead (`menuRuns`). + const first: FirstPress = { actionId: action.id, until: Number.POSITIVE_INFINITY } + if (priority === 'orbit') { + this.firstPresses.set(key, first) + void pageVerdict(contents, input, Date.now()).then((verdict) => { + if (this.firstPresses.get(key) === first) this.firstPresses.delete(key) + if (first.menuRan || contents.isDestroyed()) return + if (verdict && (!verdict.seen || (verdict.prevented && verdict.inField))) return + this.ran(action, windowId) + }) + return true + } + const earlier = this.firstPresses.get(key) + if (earlier && Date.now() <= earlier.until) { + if (input.isAutoRepeat) return true + this.firstPresses.delete(key) + return false + } + if (input.isAutoRepeat) return true + // Until the page's answer is in, another press counts as the second one. + this.firstPresses.set(key, first) + void pageVerdict(contents, input, Date.now()).then((verdict) => { + // Whether a second press came (and ran the action) before the answer. + const waiting = this.firstPresses.get(key) === first + if (first.menuRan || contents.isDestroyed()) { + if (waiting) this.firstPresses.delete(key) + return + } + if (!verdict || (verdict.seen && !verdict.prevented)) { + // The page left the key alone: the press is the action's, besides any second press's. + if (waiting) this.firstPresses.delete(key) + this.ran(action, windowId) + return + } + if (!waiting) return + first.until = Date.now() + AGAIN_MS + setTimeout(() => { + if (this.firstPresses.get(key) === first) this.firstPresses.delete(key) + }, AGAIN_MS + 50) + if (windowId) this.offer(windowId, action, input) + }) + return true + } + + /** Runs an action whose keys the page left alone. */ + private ran(action: ActionDef, windowId: WindowId | undefined): void { + this.ranAfterPage.set(action.id, Date.now()) + void this.ctx?.actions.run(action.id, { windowId }) + } + + /** + * Whether the macOS menu bar should run an action it was asked to. A key the router let through + * to a page reaches the menu when the page leaves it alone: the menu runs the action then, and + * the router doesn't run it a second time when the page's answer comes. + */ + menuRuns(actionId: string): boolean { + for (const [key, first] of this.firstPresses) { + if (first.actionId !== actionId || first.until !== Number.POSITIVE_INFINITY) continue + first.menuRan = true + this.firstPresses.delete(key) + return true + } + const ran = this.ranAfterPage.get(actionId) + this.ranAfterPage.delete(actionId) + return ran === undefined || Date.now() - ran > MENU_DOUBLE_MS + } + + /** The toast after a page used an action's keys: the action, a click away (KS-02). */ + private offer(windowId: WindowId, action: ActionDef, input: Electron.Input): void { + const accel = acceleratorFromInput(input) + const keys = accel ? formatAccelerator(accel, process.platform) : 'the shortcut' + this.ctx?.windows.sendToWindow(windowId, 'toast', { + message: `The page used ${keys}`, + action: { label: action.title, command: 'action.run', args: { id: action.id } }, + }) + } } /** The one key router extension for the app. */ diff --git a/src/main/menu.ts b/src/main/menu.ts index 35450819..ed7be71b 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -4,6 +4,7 @@ import { editsText } from '../shared/keymap' import { keepForMenu } from './archive/menus' import { recentlyClosedItems } from './archive/recently-closed' import type { MainContext } from './context' +import { keyRouter } from './keys/router' /** * The macOS menu bar, built from the shared action list so menu labels and @@ -27,6 +28,8 @@ export function installMenu(ctx: MainContext): void { // fields, and the menu must not take it from one. ...(accel && editsText(accel, 'darwin') ? { registerAccelerator: false } : {}), click: () => { + // A key let through to a page that left it alone reaches the menu too: it runs once (KS-02). + if (!keyRouter.menuRuns(id)) return const windowId = ctx.windows.focusedWindowId() // With a page's popup or detached DevTools focused, Close Tab closes that window instead. if (!windowId && (id === 'tab.close' || id === 'window.close') && ctx.windows.closeFocusedForeign()) return diff --git a/src/main/windows.ts b/src/main/windows.ts index 1375c095..295d37a5 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1067,6 +1067,10 @@ export class WindowManager { this.runOutsideText(contents, input) return } + // A web page may get the action's keys first (keys lane, KS-02, KS-16). + if (keyRouter.leaveToPage(contents, input, action, () => this.windowIdFor(contents) ?? this.focusedWindowId())) { + return + } event.preventDefault() const windowId = this.windowIdFor(contents) ?? this.focusedWindowId() // Close Tab in a window that isn't an Orbit window (a page's popup) closes that window. diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 40ed95ed..964e351d 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -65,6 +65,11 @@ export interface ActionDef { * the key ("prefer Orbit", the default). Users choose per action in Settings → Shortcuts. */ preferWebsites?: boolean | 'other' + /** + * With "prefer Orbit", the keys still go first to a page whose text field has focus, and the + * action runs only when the page leaves them alone (Back and Forward's ⌘[ and ⌘], KS-16). + */ + pageFirstInFields?: boolean } const numbered = ( @@ -218,6 +223,8 @@ export const ACTIONS: ActionDef[] = [ mac: ['Cmd+['], other: ['Alt+Left'], macOutsideText: ['Cmd+Left'], + // A text field's page may use ⌘[ / Alt+← (an editor's outdent): it goes there first (KS-16). + pageFirstInFields: true, }, { id: 'nav.forward', @@ -226,6 +233,8 @@ export const ACTIONS: ActionDef[] = [ mac: ['Cmd+]'], other: ['Alt+Right'], macOutsideText: ['Cmd+Right'], + // A text field's page may use ⌘] / Alt+→ (an editor's indent): it goes there first (KS-16). + pageFirstInFields: true, }, { id: 'nav.reload', title: 'Reload Page', category: 'Navigation', mac: ['Cmd+R'], other: ['Ctrl+R', 'F5'] }, { diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts index 4c50ebce..c57772f2 100644 --- a/tests/e2e/keys.spec.ts +++ b/tests/e2e/keys.spec.ts @@ -1,6 +1,6 @@ -import { expect, test } from '@playwright/test' -import { action, isMac, launchOrbit, MOD, type Orbit, run, state, viewPage } from './helpers' -import { cmd, type NavFixtures, openTab, startNavFixtures } from './nav-support' +import { expect, type Page, test } from '@playwright/test' +import { action, isMac, launchOrbit, MOD, type Orbit, run, state, tabPage, viewPage } from './helpers' +import { cmd, type NavFixtures, openTab, pressInPage, startNavFixtures, visit } from './nav-support' import { activeSpace, createSpace } from './spaces-support' let fx: NavFixtures @@ -261,3 +261,184 @@ test('[KS-05] ⌘⇧W / Ctrl+Shift+W closes the window, and F6 moves the keyboar .poll(async () => Object.keys((await state(app)).windows)) .toEqual([expect.not.stringMatching(`^${firstWindow}$`)]) }) + +// ---------- Pages that use Orbit's keys (KS-02) ---------- + +const SAVE = isMac ? '⌘S' : 'Ctrl+S' + +/** + * Opens a page with the keyboard. It counts its presses of ⌘/Ctrl + `code` (with Shift when + * `shift`), and with `use` takes them for itself (preventDefault), as a web editor does with ⌘S. + */ +async function pageWithKeys(title: string, keys: { code: string; shift?: boolean; use: boolean }): Promise { + const { app } = orbit + const id = await openTab(app, fx.url(`/page/${title}`)) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + const page = await tabPage(app, `/page/${title}`) + await page.evaluate( + ({ code, shift, use }) => { + const w = window as unknown as { presses: number } + w.presses = 0 + addEventListener('keydown', (e) => { + if (e.code !== code || !(e.metaKey || e.ctrlKey) || e.shiftKey !== shift) return + w.presses++ + if (use) e.preventDefault() + }) + }, + { shift: false, ...keys }, + ) + await cmd(app, 'tabs.focusPage', {}) + await expect.poll(focusedUrl).toBe(fx.url(`/page/${title}`)) + return page +} + +/** + * ⌘/Ctrl + `key` (and Shift), pressed as the keyboard does. Playwright's own key presses reach + * the page's renderer without passing Orbit's key router, so they can't show who gets the key. + */ +function pressMod(key: string, shift = false): Promise { + return pressAnywhere(key, [isMac ? 'meta' : 'control', ...(shift ? (['shift'] as const) : [])]) +} + +function presses(page: Page): Promise { + return page.evaluate(() => (window as unknown as { presses: number }).presses) +} + +test('[KS-02][SB-11] a page that uses ⌘S / Ctrl+S gets the first press and a toast offers Toggle Sidebar; a second press within a second runs it', async () => { + const { app, shell } = orbit + const page = await pageWithKeys('Editor', { code: 'KeyS', use: true }) + await pressMod('s') + await expect.poll(() => presses(page)).toBe(1) + const toast = shell.getByTestId('toast').filter({ hasText: `The page used ${SAVE}` }) + await expect(toast).toHaveCount(1) + expect(await sidebarVisible()).toBe(true) + // The toast's button runs the action. + await expect(toast.getByTestId('toast-action')).toHaveText('Toggle Sidebar') + await toast.getByTestId('toast-action').click() + await expect.poll(sidebarVisible).toBe(false) + await action(app, 'sidebar.toggle') + await expect.poll(sidebarVisible).toBe(true) + + // Pressed again soon after the page took it, the key toggles the sidebar and stays from the page. + await cmd(app, 'tabs.focusPage', {}) + await expect.poll(focusedUrl).toBe(fx.url('/page/Editor')) + await pressMod('s') + await expect.poll(() => presses(page)).toBe(2) + await expect(toast).toHaveCount(1) + await pressMod('s') + await expect.poll(sidebarVisible).toBe(false) + expect(await presses(page)).toBe(2) +}) + +test('[KS-02][SB-11] a page that leaves ⌘S / Ctrl+S alone sees each press, and each press toggles the sidebar', async () => { + const page = await pageWithKeys('Reader', { code: 'KeyS', use: false }) + await pressMod('s') + await expect.poll(sidebarVisible).toBe(false) + expect(await presses(page)).toBe(1) + await pressMod('s') + await expect.poll(sidebarVisible).toBe(true) + expect(await presses(page)).toBe(2) +}) + +test('[KS-02][ST-08] set to prefer Orbit, ⌘S / Ctrl+S toggles the sidebar at once and the page never sees it', async () => { + const { app } = orbit + await run(app, 'keys.setPriority', { actionId: 'sidebar.toggle', priority: 'orbit' }) + expect((await state(app)).settings.shortcutPriority).toEqual({ 'sidebar.toggle': 'orbit' }) + const page = await pageWithKeys('Editor', { code: 'KeyS', use: true }) + await pressMod('s') + await expect.poll(sidebarVisible).toBe(false) + expect(await presses(page)).toBe(0) + // Back to the default: the page has the first press again. + await run(app, 'keys.setPriority', { actionId: 'sidebar.toggle', priority: 'websites' }) + expect((await state(app)).settings.shortcutPriority).toBeUndefined() + await pressMod('s') + await expect.poll(() => presses(page)).toBe(1) + expect(await sidebarVisible()).toBe(false) +}) + +test('[KS-02][NV-17] Windows and Linux: a page that uses Ctrl+Shift+C gets it first and the toast offers Copy URL (macOS copies at once)', async () => { + const { app, shell } = orbit + const clipboard = () => app.evaluate(({ clipboard }) => clipboard.readText()) + await app.evaluate(({ clipboard }) => clipboard.writeText('before')) + const page = await pageWithKeys('Sheet', { code: 'KeyC', shift: true, use: true }) + await pressMod('c', true) + if (isMac) { + await expect.poll(clipboard).toBe(fx.url('/page/Sheet')) + expect(await presses(page)).toBe(0) + return + } + await expect.poll(() => presses(page)).toBe(1) + const toast = shell.getByTestId('toast').filter({ hasText: 'The page used Ctrl+Shift+C' }) + await expect(toast.getByTestId('toast-action')).toHaveText('Copy URL') + expect(await clipboard()).toBe('before') + await pressMod('c', true) + await expect.poll(clipboard).toBe(fx.url('/page/Sheet')) + expect(await presses(page)).toBe(1) +}) + +test('[KS-16][NV-01] in a page text field, ⌘[ / Alt+← goes back only when the page leaves the key alone', async () => { + const { app } = orbit + const [key, mods] = isMac ? (['[', ['meta']] as const) : (['Left', ['alt']] as const) + const id = await openTab(app, fx.url('/page/FieldA')) + await visit(app, id, fx.url('/page/FieldB')) + await visit(app, id, fx.url('/page/FieldC')) + const c = await tabPage(app, '/page/FieldC') + // An editor that outdents with the key while its field has focus. + await c.evaluate(() => { + addEventListener('keydown', (e) => { + if (document.activeElement?.id === 'box' && (e.code === 'BracketLeft' || e.code === 'ArrowLeft')) + e.preventDefault() + }) + }) + await c.focus('#box') + await pressInPage(app, '/page/FieldC', key, [...mods]) + await expect + .poll(() => c.evaluate(() => (window as unknown as { __keys: string[] }).__keys)) + .toContain(isMac ? '[' : 'ArrowLeft') + // Outside the field the key is Orbit's: one page back. Had the first press gone back too, this + // would land on A. + await c.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await pressInPage(app, '/page/FieldC', key, [...mods]) + await expect.poll(async () => (await state(app)).tabs[id]?.url).toBe(fx.url('/page/FieldB')) + // In the field of a page that leaves the key alone, it goes back. + const b = await tabPage(app, '/page/FieldB') + await b.focus('#box') + await pressInPage(app, '/page/FieldB', key, [...mods]) + await expect.poll(async () => (await state(app)).tabs[id]?.url).toBe(fx.url('/page/FieldA')) +}) + +/** + * Waits until main has had the answers to every page script it sent `urlPart`'s page before now: + * the page answers in order. + */ +function pageScriptsAnswered(urlPart: string): Promise { + return orbit.app.evaluate(async ({ webContents }, part) => { + const page = webContents.getAllWebContents().find((w) => w.getURL().includes(part)) + await page?.executeJavaScriptInIsolatedWorld(999, [{ code: '1' }]) + }, urlPart) +} + +test('[KS-16][TB-18] ⌘Z / Ctrl+Z in a page text field undoes the typing, not the last sidebar change', async () => { + const { app } = orbit + const mod = isMac ? 'meta' : 'control' + const a = await openTab(app, fx.url('/page/UndoA')) + const b = await openTab(app, fx.url('/page/UndoB')) + const id = await openTab(app, fx.url('/page/Typing')) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + await cmd(app, 'tabs.close', { tabId: a }) + await cmd(app, 'tabs.close', { tabId: b }) + const urls = async () => Object.values((await state(app)).tabs).map((t) => t.url) + expect(await urls()).toEqual([fx.url('/page/Typing')]) + const page = await tabPage(app, '/page/Typing') + await page.focus('#box') + await page.keyboard.type('hello') + await expect(page.locator('#box')).toHaveValue('hello') + await pressInPage(app, '/page/Typing', 'z', [mod]) + await expect(page.locator('#box')).toHaveValue('') + await pageScriptsAnswered('/page/Typing') + expect(await urls()).toEqual([fx.url('/page/Typing')]) + // Outside the field the key undoes the last sidebar change: B comes back, and only B. + await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur()) + await pressInPage(app, '/page/Typing', 'z', [mod]) + await expect.poll(async () => (await urls()).sort()).toEqual([fx.url('/page/Typing'), fx.url('/page/UndoB')].sort()) +}) diff --git a/tests/unit/keymap-remap.test.ts b/tests/unit/keymap-remap.test.ts index dffa07a2..e754ab85 100644 --- a/tests/unit/keymap-remap.test.ts +++ b/tests/unit/keymap-remap.test.ts @@ -10,6 +10,7 @@ import { unbind, } from '../../src/main/keys/bindings' import { + ACTIONS, ACTIONS_BY_ID, actionForAccelerator, actionForInput, @@ -233,3 +234,12 @@ describe('[KS-02] who gets an action’s keys first', () => { }) }) }) + +describe('[KS-16] keys a page’s text field may use', () => { + it('Back and Forward go to a focused text field first; no other action does', () => { + expect(ACTIONS.filter((a) => a.pageFirstInFields).map((a) => a.id)).toEqual(['nav.back', 'nav.forward']) + // They prefer Orbit: outside text fields the page never has them first. + expect(priorityFor(action('nav.back'), 'darwin')).toBe('orbit') + expect(priorityFor(action('nav.forward'), 'linux')).toBe('orbit') + }) +}) From b57561168ec452b31375534d20d6d173ea34689a Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:06:56 +0000 Subject: [PATCH 05/11] fix(keys): pages that are loading never hold Orbit's keys; W in the tab switcher; tab, window, split and overlay keys checked (KS-02, KS-05, KS-06, KS-09, KS-19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KS-02: Electron runs no isolated-world script in a page while its main frame loads, so a loading page can't say what it did with a key press. Orbit now takes the keys at once then (pageCanAnswer), and every answer it waits for has a 1 s limit. Before, ⌘S on a page that was on its way did nothing. - KS-06 / TB-05: W with ⌃ held while switching to a recent tab closes the highlighted tab; the next tab in the switcher takes the highlight. - KS-05: Rename Tab, Archive Tab and Move Tab to Space… are assignable actions with no default keys. - Main side of Settings → Shortcuts (ST-08): keys.setOptions (Go to Tab options) and keys.systemClashes (macOS Mission Control and GNOME workspace keys), with unit tests for the parsers. - e2e tests for KS-05, KS-06, KS-09 and KS-19 from a page; feature status for KS-02, KS-05, KS-06, KS-09, KS-19 and TB-05. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 16 +- docs/feature-status.json | 10 +- src/main/keys/index.ts | 14 ++ src/main/keys/page-keys.ts | 23 ++- src/main/keys/router.ts | 21 ++- src/main/keys/system.ts | 135 ++++++++++++++++ src/main/keys/tab-keys.ts | 52 +++++++ src/main/tabs/commands.ts | 19 +++ src/main/tabs/runtime.ts | 4 +- src/main/windows.ts | 5 + src/shared/actions.ts | 4 + src/shared/commands/keys.ts | 20 +++ tests/e2e/keys.spec.ts | 238 +++++++++++++++++++++++++++++ tests/unit/keymap-defaults.test.ts | 4 + tests/unit/keymap-system.test.ts | 70 +++++++++ 15 files changed, 620 insertions(+), 15 deletions(-) create mode 100644 src/main/keys/system.ts create mode 100644 src/main/keys/tab-keys.ts create mode 100644 tests/unit/keymap-system.test.ts diff --git a/FEATURES.md b/FEATURES.md index 484ee8a8..f538ab79 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,11 +4,11 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 203 built (75 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 205 built (75 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 133 | 164 | +| P0 | 174 | 135 | 164 | | P1 | 221 | 61 | 116 | | P2 | 80 | 9 | 11 | @@ -59,7 +59,7 @@ Spec: `spec/tabs.md` | TB-02 | Create a new tab | P0 | ✅ | ✅ e2e | – | Cmd+T / + New Tab / link drop (a local file address dropped opens nothing, D49) / Cmd+Alt+V paste; background link tabs gesture-gated | | TB-03 | Where new tabs are inserted | P0 | ✅ | ✅ e2e+unit | – | single rule todayInsertIndex(): top of Today | | TB-04 | Switching between tabs (pointer and keyboard) | P0 | ✅ | ✅ e2e+unit | – | click, Cmd+1-9 (Favorites setting, Cmd+9 last), Cmd+Alt+Up/Down skip collapsed rows | -| TB-05 | Recent-tab switcher (Ctrl+Tab) | P1 | 🟡 partial | ✅ e2e | – | Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); no overlay list yet | +| TB-05 | Recent-tab switcher (Ctrl+Tab) | P1 | 🟡 partial | ✅ e2e | – | Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); W with Ctrl held closes the highlighted tab (keys lane, KS-06); no overlay list yet | | TB-06 | Closing a tab | P0 | ✅ | ✅ e2e+unit | – | Today -> Archive, pinned/Favorites unload; next selection below/above/MRU; middle-click; Cmd+W closes window when empty | | TB-07 | Reopen the last closed tab | P0 | ✅ | ✅ e2e | – | Cmd+Shift+T stack with full history; Cmd+Z reopens too | | TB-08 | Reorder tabs by drag and drop | P0 | ✅ | ✅ e2e+unit | – | pointer drag, 4px threshold, rows spring apart, ghost lift/settle, auto-scroll, Escape cancel, undo; no haptics | @@ -347,14 +347,14 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| | KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08 | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | | KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08 | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | -| KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | -| KS-06 | Switching tabs | P0 | – | ✅ e2e+unit | – | | +| KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | +| KS-06 | Switching tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab (⌃` on macOS) step through recent tabs, W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05) | | KS-07 | Spaces | P0 | ✅ | ✅ e2e+unit | – | Space shortcuts wired through the action table | | KS-08 | Page navigation and page tools | P0 | 🟡 partial | ✅ e2e+unit | – | Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items | -| KS-09 | Split view | P0 | – | ✅ unit | – | | +| KS-09 | Split view | P0 | ✅ | ✅ e2e+unit | – | ⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page | | KS-10 | Peek and Mini Window | P1 | – | ✅ unit | – | | | KS-11 | Media | P1 | 🟡 partial | ✅ e2e+unit | – | Space and arrows in the focused player, Cmd/Ctrl+scroll and pinch zoom (tested), hardware media keys via Chromium (untested), Mute Tab action assignable; the calendar meeting shortcut (P2) belongs to TB-21/FV-09 | | KS-12 | Library, history, downloads, settings, extensions, help | P1 | – | ✅ e2e+unit | – | | @@ -364,7 +364,7 @@ Spec: `spec/shortcuts.md` | KS-16 | Text-editing keys keep their meaning | P0 | ✅ | ✅ e2e+unit | – | typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) from a page's text field go back or forward only when the page leaves them alone (a press into a frame Orbit can't see into stays the page's); ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router) | | KS-17 | Mouse, trackpad and modifier-click bindings | P0 | – | – | – | | | KS-18 | Shortcut hints everywhere | P1 | 🟡 partial | ✅ e2e | – | button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come | -| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e | – | command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes | +| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e | – | the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); Esc then stops a loading page, as before, even one that uses Esc itself: Electron runs no isolated-world script in a page while it loads, so Orbit can't learn in time whether the page used the key (needs a lead decision: a script in the page's own world or a preload channel, D40) | | KS-20 | Shortcuts in special windows | P1 | – | – | – | | ## Interaction feel and motion targets (prefix FL) diff --git a/docs/feature-status.json b/docs/feature-status.json index 2e05ab6a..07a1df12 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -23,7 +23,7 @@ "TB-02": {"built": "yes", "note": "Cmd+T / + New Tab / link drop (a local file address dropped opens nothing, D49) / Cmd+Alt+V paste; background link tabs gesture-gated"}, "TB-03": {"built": "yes", "note": "single rule todayInsertIndex(): top of Today"}, "TB-04": {"built": "yes", "note": "click, Cmd+1-9 (Favorites setting, Cmd+9 last), Cmd+Alt+Up/Down skip collapsed rows"}, - "TB-05": {"built": "partial", "note": "Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); no overlay list yet"}, + "TB-05": {"built": "partial", "note": "Ctrl+Tab / Ctrl+Shift+Tab step through the 5 most recent tabs while Ctrl is held (keyboard only); W with Ctrl held closes the highlighted tab (keys lane, KS-06); no overlay list yet"}, "TB-06": {"built": "yes", "note": "Today -> Archive, pinned/Favorites unload; next selection below/above/MRU; middle-click; Cmd+W closes window when empty"}, "TB-07": {"built": "yes", "note": "Cmd+Shift+T stack with full history; Cmd+Z reopens too"}, "TB-08": {"built": "yes", "note": "pointer drag, 4px threshold, rows spring apart, ghost lift/settle, auto-scroll, Escape cancel, undo; no haptics"}, @@ -135,13 +135,14 @@ "CB-23": {"built": "partial", "note": "private (non-persistent) profiles: no history/archive rows, and their tabs, folders and Spaces never show in normal windows; ⌘L on an empty Space acts like ⌘T; split-pane and Mini Window variants not done"}, "KS-04": {"built": "yes", "note": "⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V"}, "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, - "KS-19": {"built": "partial", "note": "command bar: ⌘T/⌘L/⌘W/Esc handled by the bar first (ActionRegistry.intercept); other overlays belong to their lanes"}, + "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); Esc then stops a loading page, as before, even one that uses Esc itself: Electron runs no isolated-world script in a page while it loads, so Orbit can't learn in time whether the page used the key (needs a lead decision: a script in the page's own world or a preload channel, D40)"}, "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) from a page's text field go back or forward only when the page leaves them alone (a press into a frame Orbit can't see into stays the page's); ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router)"}, "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08"}, - "KS-02": {"built": "partial", "note": "who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, "KS-18": {"built": "partial", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come"}, "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08"}, - "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T, ⌘D, ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), Duplicate and Reset to Pinned assignable; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, + "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, + "KS-06": {"built": "yes", "note": "⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab (⌃` on macOS) step through recent tabs, W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05)"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, "FL-14": {"built": "yes", "note": "theme blends with the gesture/spring in OKLab; 200 ms preset blends; light/dark cross-fade"}, "FL-16": {"built": "yes", "note": "motion spring (0.97→1, 4 px drop), 100 ms exit (80 ms after choosing a row) while focus returns at once; fade only with reduced motion; centred in the window"}, @@ -197,6 +198,7 @@ "NV-18": {"built": "yes", "note": "summary of MW-02/05/09/10/11: links from other apps open a Mini Window or follow routing rules, else a Today tab in the most recent Space; never in a private window (e2e); a launch with --private-window takes its links to a private window (NV-11); rules UI is ST-09, Archive restores AR-05"}, "NV-17": {"built": "yes", "note": "copy button = Cmd+Shift+C (clean URL, Copied toast); one-time shortcut tip when the URL is copied from the command bar; on Windows/Linux a page that uses Ctrl+Shift+C gets it first and a toast offers Copy URL, a second press within a second copies (KS-02)"}, "KS-08": {"built": "partial", "note": "Back, Forward, Reload, hard reload, Stop, find (Cmd+F, Cmd+G, Shift+Cmd+G, F3, Ctrl+G), Cmd+J and zoom keys done; print, save page, reader mode and the rest come with their NV items"}, + "KS-09": {"built": "yes", "note": "⌃⇧= adds a pane the command bar fills, ⌃⇧− closes the focused pane, ⌃⇧1–4 focus a pane, ⌃⇧] / ⌃⇧[ the next / previous pane (Ctrl+Shift on Windows/Linux), ⌘L / Ctrl+L changes the focused pane's address (SV-15), Add Right/Left/Above/Below Split from the command bar; tested from a page"}, "FL-15": {"built": "partial", "note": "top row: Back/Forward nudge, Reload glyph turns once, press to 0.94 with pop release, restarts on repeat, none when disabled or reduced motion"}, "FL-23": {"built": "yes", "note": "after 150 ms, a 48x4 theme-accent pill sweeping on a 1.2 s cycle (a pulse with reduced motion); on load end it fills (80 ms) and fades (150 ms), gone 180 ms after; never moves page content"}, "FL-02": {"built": "yes", "note": "260 ms standard curve drives the sidebar column and the card margins together; interruptible; reduced motion snaps"}, diff --git a/src/main/keys/index.ts b/src/main/keys/index.ts index 383c8e20..9d975823 100644 --- a/src/main/keys/index.ts +++ b/src/main/keys/index.ts @@ -4,6 +4,8 @@ import type { MainContext } from '../context' import { installMenu } from '../menu' import { bind, reset, setPriority, unbind } from './bindings' import { keyRouter } from './router' +import { setSystemClashesForTest, systemClashes } from './system' +import { registerTabKeys } from './tab-keys' import { registerWindowKeys } from './window-keys' /** @@ -65,7 +67,19 @@ export function registerKeys(ctx: MainContext): void { if (!text(actionId)) return savePriorities(setPriority(store.state.settings.shortcutPriority, actionId, priority, platform)) }) + commands.register('keys.setOptions', ({ countFavorites, lastTabOnCmd9 }) => + store.update((d) => { + if (typeof countFavorites === 'boolean') d.settings.countFavoritesInShortcuts = countFavorites + if (typeof lastTabOnCmd9 === 'boolean') d.settings.lastTabOnCmd9 = lastTabOnCmd9 + }), + ) + commands.register('keys.systemClashes', () => systemClashes()) registerWindowKeys(ctx) + registerTabKeys(ctx) keyRouter.init(ctx) commands.register('keys.record', ({ on }, c) => keyRouter.setRecording(on ? c.windowId : undefined)) + // Tests say which keys the system keeps instead of asking it. + if (ctx.e2e) { + ;(globalThis as unknown as { __orbitKeys: unknown }).__orbitKeys = { setSystemClashes: setSystemClashesForTest } + } } diff --git a/src/main/keys/page-keys.ts b/src/main/keys/page-keys.ts index 9c4334ab..fca33688 100644 --- a/src/main/keys/page-keys.ts +++ b/src/main/keys/page-keys.ts @@ -85,6 +85,18 @@ export type PageVerdict = /** How long to wait for the page's answer before taking it that the page kept the key. */ export const VERDICT_WAIT_MS = 250 +/** How long main waits for an answer at all: a page whose renderer is busy or hung answers late. */ +const ANSWER_WAIT_MS = 1000 + +/** + * Whether the page can say at once what it did with a key press. Electron runs no isolated-world + * script in a page while its main frame loads (the script waits until the load ends), so a page + * on its way, or one still loading the first document's resources, can't answer in time. + */ +export function pageCanAnswer(contents: WebContents): boolean { + return !contents.isDestroyed() && !contents.isCrashed() && !contents.isLoadingMainFrame() +} + /** * What the page did with the key press `input`, pressed at `pressedAt` (main's clock, which the * page shares). Asked as the press is let through to the page. @@ -94,12 +106,21 @@ export async function pageVerdict( input: Electron.Input, pressedAt: number, ): Promise { + if (!pageCanAnswer(contents)) return undefined const sig = [input.meta, input.control, input.alt, input.shift, input.code].join(',') const code = `window.__orbitKeys ? window.__orbitKeys.verdict(${JSON.stringify(sig)}, ${pressedAt - 10}, ${VERDICT_WAIT_MS}) : null` + let timer: NodeJS.Timeout | undefined try { - const verdict = (await contents.executeJavaScriptInIsolatedWorld(KEYS_WORLD, [{ code }])) as PageVerdict | null + const verdict = (await Promise.race([ + contents.executeJavaScriptInIsolatedWorld(KEYS_WORLD, [{ code }]), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), ANSWER_WAIT_MS) + }), + ])) as PageVerdict | null return verdict ?? undefined } catch { return undefined + } finally { + clearTimeout(timer) } } diff --git a/src/main/keys/router.ts b/src/main/keys/router.ts index 5642c729..24c661be 100644 --- a/src/main/keys/router.ts +++ b/src/main/keys/router.ts @@ -4,7 +4,7 @@ import type { RecordedKey } from '../../shared/commands/keys' import { acceleratorFromInput, modifiersOf } from '../../shared/keymap' import type { WindowId } from '../../shared/state' import type { MainContext } from '../context' -import { pageVerdict, watchPageKeys } from './page-keys' +import { pageCanAnswer, pageVerdict, watchPageKeys } from './page-keys' /** A second press of the same keys within this long after the page took the first runs the action (KS-02). */ export const AGAIN_MS = 1000 @@ -95,6 +95,21 @@ export class KeyRouter { return true } + /** + * W, with Control still held, while switching to a recent tab (⌃Tab) closes the highlighted tab + * (KS-06, TB-05); a held W closes one. Returns true when the press was the switcher's: the + * caller keeps it from the page and every shortcut (Ctrl+W, Ctrl+Shift+W). + */ + switcherKey(input: Electron.Input, windowOf: () => WindowId | undefined): boolean { + if (input.type !== 'keyDown' || !input.control || input.meta || input.alt) return false + if (input.code !== 'KeyW' && input.key.toLowerCase() !== 'w') return false + const windowId = windowOf() + const recent = this.ctx?.tabs.recentSwitch + if (!windowId || !recent?.active(windowId)) return false + if (!input.isAutoRepeat) recent.closeHighlighted(windowId) + return true + } + /** * Keys a web page may use (KS-02, KS-16). Returns true when the press goes to the page: the * caller lets it through and runs nothing now. Applies to a tab's page and: @@ -105,6 +120,8 @@ export class KeyRouter { * - an action that prefers Orbit but leaves its keys to text fields (Back and Forward's ⌘[ ⌘]): * the action runs unless focus was in a text field and the page used the press, or the press * went into a frame Orbit can't see into. + * A page whose main frame is loading can't say what it did with a press in time: Orbit takes + * the keys at once then. */ leaveToPage( contents: WebContents, @@ -116,6 +133,8 @@ export class KeyRouter { if (!ctx?.tabs.tabIdFor(contents)) return false const priority = priorityFor(action, process.platform, ctx.store.state.settings.shortcutPriority) if (priority === 'orbit' && !action.pageFirstInFields) return false + // A page that is loading can't say in time what it did with the key: Orbit takes it at once. + if (!pageCanAnswer(contents)) return false const key = `${contents.id} ${acceleratorFromInput(input) ?? ''}` const windowId = windowOf() // Until the page's answer is in, the macOS menu may run the action instead (`menuRuns`). diff --git a/src/main/keys/system.ts b/src/main/keys/system.ts new file mode 100644 index 00000000..214078b0 --- /dev/null +++ b/src/main/keys/system.ts @@ -0,0 +1,135 @@ +import { execFile } from 'node:child_process' +import type { SystemClash } from '../../shared/commands/keys' +import { canonicalAccelerator } from '../../shared/keymap' + +/** + * Keys the operating system keeps for itself, so Orbit never receives them (KS-03): macOS's + * Mission Control "Switch to Desktop N" (⌃1–⌃9 when turned on) and GNOME's workspace keys + * (Ctrl+Alt+←/→ on many setups). Settings → Shortcuts shows them next to the actions they block. + * Other desktops and Windows report nothing. + */ + +const run = (file: string, args: string[]): Promise => + new Promise((resolve) => { + execFile(file, args, { timeout: 3000 }, (error, stdout) => resolve(error ? '' : String(stdout))) + }) + +/** macOS virtual key codes of the digit keys. */ +const MAC_DIGITS: Record = { + 18: '1', + 19: '2', + 20: '3', + 21: '4', + 23: '5', + 22: '6', + 26: '7', + 28: '8', + 25: '9', +} + +/** + * The Mission Control desktop keys turned on, from `defaults read com.apple.symbolichotkeys + * AppleSymbolicHotKeys` (entries 118–126 are Switch to Desktop 1–9). + */ +export function missionControlClashes(plist: string): SystemClash[] { + const out: SystemClash[] = [] + const entry = + /(?:^|[\s{;])"?(\d+)"?\s*=\s*\{\s*enabled\s*=\s*(\d+);\s*value\s*=\s*\{\s*parameters\s*=\s*\(\s*(\d+),\s*(\d+),\s*(\d+)\s*\);/g + for (const m of plist.matchAll(entry)) { + const id = Number(m[1]) + if (id < 118 || id > 126 || m[2] !== '1') continue + const digit = MAC_DIGITS[Number(m[4])] + const mask = Number(m[5]) + if (!digit) continue + const mods = [ + mask & 1048576 ? 'Cmd' : '', + mask & 262144 ? 'Ctrl' : '', + mask & 524288 ? 'Alt' : '', + mask & 131072 ? 'Shift' : '', + ].filter(Boolean) + const accel = canonicalAccelerator([...mods, digit].join('+')) + if (accel) out.push({ accel, owner: `Mission Control: Switch to Desktop ${id - 117}` }) + } + return out +} + +const GNOME_KEYS: Record = { + Left: 'Left', + Right: 'Right', + Up: 'Up', + Down: 'Down', + Page_Up: 'PageUp', + Page_Down: 'PageDown', + Home: 'Home', + End: 'End', +} + +/** A GNOME accelerator ("Left") as a binding, when Orbit could have it. */ +export function gnomeAccelerator(text: string): string | undefined { + const mods: string[] = [] + let rest = text.trim() + for (;;) { + const m = /^<([A-Za-z0-9]+)>/.exec(rest) + if (!m) break + rest = rest.slice(m[0].length) + const name = (m[1] ?? '').toLowerCase() + if (name === 'control' || name === 'primary' || name === 'ctrl') mods.push('Ctrl') + else if (name === 'alt' || name === 'mod1') mods.push('Alt') + else if (name === 'shift') mods.push('Shift') + // Super (the Windows key) and the rest are keys Orbit never binds. + else return undefined + } + const key = GNOME_KEYS[rest] ?? (/^F([1-9]|1[0-2])$/.test(rest) ? rest : /^[a-z0-9]$/i.test(rest) ? rest : undefined) + if (!key || mods.length === 0) return undefined + return canonicalAccelerator([...mods, key].join('+')) +} + +const GNOME_SETTINGS: [key: string, owner: string][] = [ + ['switch-to-workspace-left', 'GNOME: Switch to workspace on the left'], + ['switch-to-workspace-right', 'GNOME: Switch to workspace on the right'], + ['switch-to-workspace-up', 'GNOME: Switch to workspace above'], + ['switch-to-workspace-down', 'GNOME: Switch to workspace below'], + ['move-to-workspace-left', 'GNOME: Move window one workspace to the left'], + ['move-to-workspace-right', 'GNOME: Move window one workspace to the right'], +] + +/** GNOME's window manager keys, from `gsettings get org.gnome.desktop.wm.keybindings ` (a list such as ['Page_Up', 'Left']). */ +export function gnomeClashes(values: Record): SystemClash[] { + const out: SystemClash[] = [] + for (const [key, owner] of GNOME_SETTINGS) { + for (const m of (values[key] ?? '').matchAll(/'([^']*)'/g)) { + const accel = gnomeAccelerator(m[1] ?? '') + if (accel) out.push({ accel, owner }) + } + } + return out +} + +async function detect(platform: string): Promise { + if (platform === 'darwin') { + return missionControlClashes(await run('defaults', ['read', 'com.apple.symbolichotkeys', 'AppleSymbolicHotKeys'])) + } + if (platform === 'linux') { + const values: Record = {} + for (const [key] of GNOME_SETTINGS) { + values[key] = await run('gsettings', ['get', 'org.gnome.desktop.wm.keybindings', key]) + } + return gnomeClashes(values) + } + return [] +} + +let found: Promise | undefined +let override: SystemClash[] | undefined + +/** The system's keys that Orbit can't receive, looked up once per run. */ +export function systemClashes(platform = process.platform): Promise { + if (override) return Promise.resolve(override) + found ??= detect(platform).catch(() => []) + return found +} + +/** Tests: what the system keeps, instead of asking it. */ +export function setSystemClashesForTest(list: SystemClash[] | undefined): void { + override = list +} diff --git a/src/main/keys/tab-keys.ts b/src/main/keys/tab-keys.ts new file mode 100644 index 00000000..5f3867ed --- /dev/null +++ b/src/main/keys/tab-keys.ts @@ -0,0 +1,52 @@ +import { sectionOf, splitOfTab } from '../../shared/tree' +import type { MainContext } from '../context' +import type { CallContext } from '../registry' +import { activeTabOf } from '../windows' + +/** + * Tab actions from KS-05's table that have no keys until the user gives them some (Settings → + * Shortcuts): Rename Tab (TB-12), Archive Tab and Move Tab to Space (TB-11). Each acts on the + * selected tab of a main window. + */ +export function registerTabKeys(ctx: MainContext): void { + const { actions, commands, store, windows } = ctx + + const selected = (c: CallContext) => { + const windowId = c.windowId ?? windows.focusedWindowId() + if (!windowId || store.state.windows[windowId]?.kind !== 'main') return undefined + const tabId = activeTabOf(store.state, windowId) + return tabId ? { windowId, tabId } : undefined + } + + // The name is edited in the tab's sidebar row (a split's in its split row): a hidden sidebar shows. + actions.register('tab.rename', (c) => { + const at = selected(c) + if (!at) return + const { windowId, tabId } = at + if (!store.state.windows[windowId]?.sidebar.visible) { + void commands.run('window.setSidebar', { visible: true }, { windowId }) + } + windows.sendToWindow(windowId, 'sidebar:rename', { id: splitOfTab(store.state, tabId) ?? tabId }) + }) + + // A Today tab closes into the Archive (TB-06); a pinned tab or Favorite leaves the sidebar for it (PN-06). + actions.register('tab.archive', (c) => { + const at = selected(c) + if (!at) return + const { windowId, tabId } = at + const section = sectionOf(store.state, splitOfTab(store.state, tabId) ?? tabId) + if (section === 'pinned' || section === 'favorite') void commands.run('tabs.delete', { id: tabId }, { windowId }) + else void commands.run('tabs.close', { tabId }, { windowId }) + }) + + // The command bar, on its "Move to [Space]" rows. + actions.register('tab.pickSpace', (c) => { + const at = selected(c) + if (!at) return + void commands.run( + 'overlay.open', + { overlay: { kind: 'commandbar', mode: 'new-tab', query: 'Move to ' } }, + { windowId: at.windowId }, + ) + }) +} diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index 56db6f44..903e74ef 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -664,6 +664,25 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM actions.register('tab.recent', stepRecent(1)) actions.register('tab.recentBack', stepRecent(-1)) tabs.onControlUp = endRecent + // W, with Control still held, closes the highlighted tab; the next one in the switcher takes its + // place and switching goes on (TB-05, keys lane KS-06). + tabs.recentSwitch = { + active: (windowId) => recent?.windowId === windowId, + closeHighlighted: (windowId) => { + const r = recent + if (!r || r.windowId !== windowId) return + const closing = r.order[r.index] + if (!closing) return + r.order.splice(r.index, 1) + r.times.delete(closing) + if (r.index >= r.order.length) r.index = 0 + ops.closeTabs([closing], 'closed') + const next = r.order[r.index] + if (next && store.state.tabs[next]) ops.activate(windowId, next) + if (r.timer) clearTimeout(r.timer) + r.timer = setTimeout(endRecent, 1500) + }, + } commands.register('tabs.endRecentSwitch', () => endRecent()) for (let n = 1; n <= 9; n++) { actions.register(`tab.goto.${n}`, (c) => { diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 7f84451c..e745a838 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -12,7 +12,7 @@ import { import { framesMayOpen } from '../../shared/link-routing' import { reopenReferrerPolicy } from '../../shared/nav' import { isPrivateProfileId } from '../../shared/private' -import type { Tab, TabId } from '../../shared/state' +import type { Tab, TabId, WindowId } from '../../shared/state' import { isAwayFromPinned, sameDocUrl } from '../../shared/tabs' import type { MainContext } from '../context' import { KEEP_KEYBOARD } from '../keys/focus' @@ -158,6 +158,8 @@ export class TabRuntime { onPageClosed?: (tabId: TabId) => void /** Control was released in a page (ends recent-tab switching, TB-05). */ onControlUp?: () => void + /** Recent-tab switching in a window (TB-05): whether it is going on, and closing its highlighted tab (W, KS-06). */ + recentSwitch?: { active: (windowId: WindowId) => boolean; closeHighlighted: (windowId: WindowId) => void } /** A page tried to open a tab or pop-up without a user gesture. */ onPopupBlocked?: (tabId: TabId, url: string, referrer: Referrer) => void /** True when the tab's site may open pop-ups without a click (allowed in site controls, NV-09). */ diff --git a/src/main/windows.ts b/src/main/windows.ts index 295d37a5..fd1407d5 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -1062,6 +1062,11 @@ export class WindowManager { return } if (input.type !== 'keyDown') return + // W while switching to a recent tab closes the highlighted tab (keys lane, KS-06). + if (keyRouter.switcherKey(input, () => this.windowIdFor(contents) ?? this.focusedWindowId())) { + event.preventDefault() + return + } const action = actionForInput(input, process.platform) if (!action || !this.ctx.actions.has(action.id)) { this.runOutsideText(contents, input) diff --git a/src/shared/actions.ts b/src/shared/actions.ts index 964e351d..af60142a 100644 --- a/src/shared/actions.ts +++ b/src/shared/actions.ts @@ -133,6 +133,10 @@ export const ACTIONS: ActionDef[] = [ }, { id: 'tab.togglePin', title: 'Pin / Unpin Tab', category: 'Tabs', mac: ['Cmd+D'], other: ['Ctrl+D'] }, { id: 'tab.duplicate', title: 'Duplicate Tab', category: 'Tabs' }, + // KS-05's assignable tab actions (no default keys). + { id: 'tab.rename', title: 'Rename Tab', category: 'Tabs' }, + { id: 'tab.archive', title: 'Archive Tab', category: 'Tabs' }, + { id: 'tab.pickSpace', title: 'Move Tab to Space…', category: 'Tabs', keywords: ['move to', 'space'] }, { id: 'tab.resetToPinned', title: 'Reset Tab', category: 'Tabs', keywords: ['pinned url', 'home', 'go back'] }, { id: 'tab.favorite', diff --git a/src/shared/commands/keys.ts b/src/shared/commands/keys.ts index 91474928..8bc2cd99 100644 --- a/src/shared/commands/keys.ts +++ b/src/shared/commands/keys.ts @@ -41,6 +41,22 @@ export interface RecordedKey { modifiers: string } +/** A key the operating system keeps for itself, so Orbit never receives it (KS-03). */ +export interface SystemClash { + /** The binding, written as `canonicalAccelerator` writes it. */ + accel: string + /** What has it, e.g. "Mission Control: Switch to Desktop 1". */ + owner: string +} + +/** The Go to Tab options (TB-04, FV-10, ST-08). */ +export interface TabNumberOptions { + /** ⌘1–⌘8 count Favorites first. */ + countFavorites?: boolean + /** ⌘9 goes to the last item (true) or the ninth (false). */ + lastTabOnCmd9?: boolean +} + export interface KeyCommands { /** Gives an action a binding (KS-01, ST-08). See BindRequest. */ 'keys.bind': { args: BindRequest; result: BindResult } @@ -57,4 +73,8 @@ export interface KeyCommands { 'keys.record': { args: { on: boolean }; result: void } /** Who gets the action's keys first when a page also handles them (KS-02). */ 'keys.setPriority': { args: { actionId: string; priority: ShortcutPriority }; result: void } + /** The Go to Tab options of Settings → Shortcuts (ST-08). */ + 'keys.setOptions': { args: TabNumberOptions; result: void } + /** Keys the operating system keeps, so Orbit never receives them (KS-03): shown in Settings → Shortcuts. */ + 'keys.systemClashes': { args: Record; result: SystemClash[] } } diff --git a/tests/e2e/keys.spec.ts b/tests/e2e/keys.spec.ts index c57772f2..93b36c93 100644 --- a/tests/e2e/keys.spec.ts +++ b/tests/e2e/keys.spec.ts @@ -407,6 +407,20 @@ test('[KS-16][NV-01] in a page text field, ⌘[ / Alt+← goes back only when th await expect.poll(async () => (await state(app)).tabs[id]?.url).toBe(fx.url('/page/FieldA')) }) +test('[KS-02][KS-16] while a page is on its way, Orbit takes its keys at once: ⌘S / Ctrl+S toggles the sidebar, and ⌘[ / Alt+← in a text field goes back', async () => { + const { app } = orbit + const [key, mods] = isMac ? (['[', ['meta']] as const) : (['Left', ['alt']] as const) + const id = await openTab(app, fx.url('/page/WayA')) + await visit(app, id, fx.url('/page/WayB')) + const b = await tabPage(app, '/page/WayB') + await b.focus('#box') + await slowLoad(id, '/slow/WayC') + await pressInPage(app, '/page/WayB', 's', [isMac ? 'meta' : 'control']) + await expect.poll(sidebarVisible).toBe(false) + await pressInPage(app, '/page/WayB', key, [...mods]) + await expect.poll(async () => (await state(app)).tabs[id]?.url).toBe(fx.url('/page/WayA')) +}) + /** * Waits until main has had the answers to every page script it sent `urlPart`'s page before now: * the page answers in order. @@ -442,3 +456,227 @@ test('[KS-16][TB-18] ⌘Z / Ctrl+Z in a page text field undoes the typing, not t await pressInPage(app, '/page/Typing', 'z', [mod]) await expect.poll(async () => (await urls()).sort()).toEqual([fx.url('/page/Typing'), fx.url('/page/UndoB')].sort()) }) + +// ---------- Tab, window, split and overlay keys (KS-05, KS-06, KS-09, KS-19) ---------- + +type Mod = 'shift' | 'control' | 'alt' | 'meta' + +/** Key events, each sent to whatever has the keyboard at that moment, as the keyboard does. */ +function keyEvents(events: [type: 'keyDown' | 'keyUp', keyCode: string, modifiers: Mod[]][]): Promise { + return orbit.app.evaluate(({ webContents }, list) => { + for (const [type, keyCode, modifiers] of list) { + webContents.getFocusedWebContents()?.sendInputEvent({ type, keyCode, modifiers }) + } + }, events) +} + +/** Selects a tab and gives its page the keyboard. */ +async function selectPage(tabId: string, url: string): Promise { + await cmd(orbit.app, 'tabs.activate', { tabId }) + await cmd(orbit.app, 'tabs.focusPage', {}) + await expect.poll(focusedUrl).toBe(url) +} + +/** The active tab of the first window. */ +async function activeTab(): Promise { + const win = Object.values((await state(orbit.app)).windows)[0] + return win?.activeTab[win.spaceId] ?? undefined +} + +test('[KS-05][PN-02][TB-07] from a page: ⌘D pins a Today tab and ⌘D again puts it back on top of Today; ⌘⇧T after ⌘W restores a closed tab in its place', async () => { + const { app } = orbit + const spaceId = await activeSpace(app) + const today = async () => (await state(app)).spaces[spaceId]?.today ?? [] + const pinned = async () => (await state(app)).spaces[spaceId]?.pinned ?? [] + const c = await openTab(app, fx.url('/page/Third')) + const b = await openTab(app, fx.url('/page/Second')) + const a = await openTab(app, fx.url('/page/First')) + expect(await today()).toEqual([a, b, c]) + await selectPage(b, fx.url('/page/Second')) + await pressMod('D') + await expect.poll(pinned).toEqual([b]) + expect(await today()).toEqual([a, c]) + await pressMod('D') + await expect.poll(today).toEqual([b, a, c]) + expect(await pinned()).toEqual([]) + // The bottom tab closes and comes back where it was, selected. + await selectPage(c, fx.url('/page/Third')) + await pressMod('W') + await expect.poll(today).toEqual([b, a]) + // The keyboard is somewhere else now (the next selected tab). + await expect + .poll(async () => { + const url = await focusedUrl() + return !!url && url !== fx.url('/page/Third') + }) + .toBe(true) + await pressMod('T', true) + await expect.poll(async () => (await today()).length).toBe(3) + const back = (await today())[2] as string + expect((await state(app)).tabs[back]?.url).toBe(fx.url('/page/Third')) + await expect.poll(activeTab).toBe(back) +}) + +test('[KS-06][TB-04][FV-10] from a page, with 2 Favorites, 1 pinned and 2 Today tabs: ⌘3 is the pinned tab, ⌘9 the last Today tab, ⌘⌥↓ goes on to the first Today tab, the ⌘⇧] / Ctrl+PgDn aliases step too, and ⌘9 can be the ninth item', async () => { + const { app } = orbit + const spaceId = await activeSpace(app) + const favorites = { kind: 'favorites', profileId: 'p-default' } + await cmd(app, 'tabs.create', { url: fx.url('/page/Fav1'), container: favorites, activate: false }) + await cmd(app, 'tabs.create', { url: fx.url('/page/Fav2'), container: favorites, activate: false }) + const pin = await cmd(app, 'tabs.create', { + url: fx.url('/page/Pin'), + container: { kind: 'pinned', spaceId }, + activate: false, + }) + const last = await openTab(app, fx.url('/page/TodayLast')) + const first = await openTab(app, fx.url('/page/TodayFirst')) + await selectPage(first, fx.url('/page/TodayFirst')) + const mod: Mod = isMac ? 'meta' : 'control' + await pressMod('3') + await expect.poll(activeTab).toBe(pin) + await pressMod('9') + await expect.poll(activeTab).toBe(last) + await selectPage(pin, fx.url('/page/Pin')) + await pressAnywhere('Down', [mod, 'alt']) + await expect.poll(activeTab).toBe(first) + await expect.poll(focusedUrl).toBe(fx.url('/page/TodayFirst')) + if (isMac) await pressAnywhere(']', ['meta', 'shift']) + else await pressAnywhere('PageDown', ['control']) + await expect.poll(activeTab).toBe(last) + await expect.poll(focusedUrl).toBe(fx.url('/page/TodayLast')) + if (isMac) await pressAnywhere('[', ['meta', 'shift']) + else await pressAnywhere('PageUp', ['control']) + await expect.poll(activeTab).toBe(first) + // With eleven items, ⌘9 is the last one, or the ninth when set so (Settings → Shortcuts). + const more: string[] = [] + for (let n = 1; n <= 6; n++) { + more.push(await cmd(app, 'tabs.create', { url: fx.url(`/page/More${n}`), activate: false })) + } + await expect.poll(focusedUrl).toBe(fx.url('/page/TodayFirst')) + await pressMod('9') + await expect.poll(activeTab).toBe(last) + await cmd(app, 'keys.setOptions', { lastTabOnCmd9: false }) + await expect.poll(focusedUrl).toBe(fx.url('/page/TodayLast')) + await pressMod('9') + // Favorites 1–2, pinned 3, then Today from the top: More6 … More1, TodayFirst, TodayLast. + await expect.poll(activeTab).toBe(more[0]) +}) + +test('[KS-06][TB-05] holding ⌃: Tab and ⇧Tab step through recent tabs, W closes the highlighted one and switching goes on from the next, and letting go settles', async () => { + const { app } = orbit + const a = await openTab(app, fx.url('/page/RecentA')) + const b = await openTab(app, fx.url('/page/RecentB')) + const c = await openTab(app, fx.url('/page/RecentC')) + const d = await openTab(app, fx.url('/page/RecentD')) + // Most recent first: D, A, C, B. The sidebar shows D, C, B, A. + await selectPage(a, fx.url('/page/RecentA')) + await selectPage(d, fx.url('/page/RecentD')) + const ctrl: Mod[] = ['control'] + const tab = (mods: Mod[]): [type: 'keyDown' | 'keyUp', keyCode: string, modifiers: Mod[]][] => [ + ['keyDown', 'Tab', mods], + ['keyUp', 'Tab', mods], + ] + await keyEvents([['keyDown', 'Control', ctrl], ...tab(ctrl)]) + await expect.poll(activeTab).toBe(a) + await keyEvents(tab(ctrl)) + await expect.poll(activeTab).toBe(c) + await keyEvents([['keyDown', 'Shift', ['control', 'shift']], ...tab(['control', 'shift']), ['keyUp', 'Shift', ctrl]]) + await expect.poll(activeTab).toBe(a) + // W closes A; C, next in the switcher, is highlighted in its place. + await keyEvents([ + ['keyDown', 'W', ctrl], + ['keyUp', 'W', ctrl], + ]) + await expect.poll(async () => (await state(app)).tabs[a]).toBeUndefined() + await expect.poll(activeTab).toBe(c) + await keyEvents(tab(ctrl)) + await expect.poll(activeTab).toBe(b) + await keyEvents([['keyUp', 'Control', []]]) + // Settled on B: a quick ⌃Tab goes back to D, the tab used before it. + await keyEvents([['keyDown', 'Control', ctrl], ...tab(ctrl), ['keyUp', 'Control', []]]) + await expect.poll(activeTab).toBe(d) + expect(Object.keys((await state(app)).tabs).sort()).toEqual([b, c, d].sort()) +}) + +test('[KS-09][SV-02][SV-16] from a page: ⌃⇧= then Enter on a result shows two panes, ⌃⇧1 focuses the left pane, ⌃⇧] and ⌃⇧[ step, and ⌃⇧− closes the focused pane', async () => { + const { app, shell } = orbit + const left = await openTab(app, fx.url('/page/LeftPane')) + await selectPage(left, fx.url('/page/LeftPane')) + const split: Mod[] = ['control', 'shift'] + await pressAnywhere('=', split) + const input = (await viewPage(app, 'overlay')).getByTestId('command-input') + await expect(input).toBeFocused() + await input.fill(fx.url('/page/RightPane')) + await input.press('Enter') + await expect(shell.getByTestId('split-row').getByTestId('split-segment')).toHaveCount(2) + const right = (await activeTab()) as string + expect(right).not.toBe(left) + await expect.poll(focusedUrl).toBe(fx.url('/page/RightPane')) + await pressAnywhere('1', split) + await expect.poll(activeTab).toBe(left) + await expect.poll(focusedUrl).toBe(fx.url('/page/LeftPane')) + await pressAnywhere(']', split) + await expect.poll(activeTab).toBe(right) + await expect.poll(focusedUrl).toBe(fx.url('/page/RightPane')) + await pressAnywhere('[', split) + await expect.poll(activeTab).toBe(left) + await expect.poll(focusedUrl).toBe(fx.url('/page/LeftPane')) + await pressAnywhere('-', split) + await expect.poll(async () => (await state(app)).tabs[left]).toBeUndefined() + await expect.poll(activeTab).toBe(right) +}) + +test('[KS-19][PK-09][NV-04] with a Peek open, Esc closes the Peek and the tab under it keeps loading', async () => { + const { app } = orbit + const id = await openTab(app, fx.url('/page/Under')) + await slowLoad(id, '/slow/UnderNext') + await cmd(app, 'peek.open', { url: fx.url('/page/Peeked') }) + await expect.poll(focusedUrl).toBe(fx.url('/page/Peeked')) + await pressAnywhere('Escape') + await expect.poll(async () => Object.values((await state(app)).windows)[0]?.overlay.kind).toBe('none') + expect((await state(app)).tabs[id]?.loading).toBe(true) + await finishLoad(id, '/slow/UnderNext') +}) + +test('[KS-05][TB-12][TB-11] Rename Tab, Move Tab to Space… and Archive Tab have no keys until given some, then work from a page', async () => { + const { app, shell } = orbit + const spaceId = await activeSpace(app) + await createSpace(app, { name: 'Other', activate: false }) + for (const [actionId, accel] of [ + ['tab.rename', 'F2'], + ['tab.pickSpace', 'F7'], + ['tab.archive', 'F8'], + ]) { + expect(await run(app, 'keys.bind', { actionId, accel })).toEqual({ ok: true }) + } + const id = await cmd(app, 'tabs.create', { + url: fx.url('/page/Named'), + container: { kind: 'pinned', spaceId }, + }) + await expect.poll(async () => (await state(app)).tabs[id]?.loading).toBe(false) + await selectPage(id, fx.url('/page/Named')) + // Renaming shows a hidden sidebar: the name is edited in the tab's row. + await action(app, 'sidebar.toggle') + await expect.poll(sidebarVisible).toBe(false) + await pressAnywhere('F2') + await expect.poll(sidebarVisible).toBe(true) + const field = shell.getByTestId('rename-field') + await expect(field).toBeFocused() + await field.fill('Mine') + await field.press('Enter') + await expect(shell.locator(`[data-testid="tab-row"][data-tab-id="${id}"]`).getByTestId('tab-title')).toHaveText( + 'Mine', + ) + await selectPage(id, fx.url('/page/Named')) + await pressAnywhere('F7') + const overlay = await viewPage(app, 'overlay') + await expect(overlay.getByTestId('command-input')).toHaveValue('Move to ') + await expect(overlay.getByTestId('command-result').first().getByTestId('command-result-title')).toHaveText( + 'Move to Other', + ) + await overlay.getByTestId('command-input').press('Escape') + await selectPage(id, fx.url('/page/Named')) + await pressAnywhere('F8') + await expect.poll(async () => (await state(app)).spaces[spaceId]?.pinned).toEqual([]) + expect((await state(app)).archive.at(-1)?.url).toBe(fx.url('/page/Named')) +}) diff --git a/tests/unit/keymap-defaults.test.ts b/tests/unit/keymap-defaults.test.ts index ec018ce4..c8ff81a4 100644 --- a/tests/unit/keymap-defaults.test.ts +++ b/tests/unit/keymap-defaults.test.ts @@ -29,6 +29,10 @@ const TABLE: [id: string, mac: string[], other: string[]][] = [ ['focus.cycle', ['F6'], ['F6']], ['tab.duplicate', [], []], ['tab.resetToPinned', [], []], + ['tab.rename', [], []], + ['tab.pickSpace', [], []], + ['tab.archive', [], []], + ['tab.favorite', [], []], // KS-06 ...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((n): [string, string[], string[]] => [ `tab.goto.${n}`, diff --git a/tests/unit/keymap-system.test.ts b/tests/unit/keymap-system.test.ts new file mode 100644 index 00000000..bd40af46 --- /dev/null +++ b/tests/unit/keymap-system.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { gnomeAccelerator, gnomeClashes, missionControlClashes } from '../../src/main/keys/system' + +/** + * Keys the operating system keeps (KS-03, ST-08): what Settings → Shortcuts reads from macOS's + * Mission Control settings and GNOME's window manager keys. + */ + +/** One entry as `defaults read com.apple.symbolichotkeys AppleSymbolicHotKeys` prints it. */ +const entry = (id: number, enabled: number, key: number, mask: number) => ` ${id} = { + enabled = ${enabled}; + value = { + parameters = ( + 65535, + ${key}, + ${mask} + ); + type = standard; + }; + };` + +describe('[KS-03][ST-08] Mission Control desktop keys', () => { + it('reads the Switch to Desktop keys that are turned on', () => { + const plist = [ + '{', + entry(32, 1, 126, 8650752), // Mission Control itself: not a desktop key + entry(118, 1, 18, 262144), // Desktop 1: ⌃1 + entry(119, 1, 19, 262144), // Desktop 2: ⌃2 + entry(120, 0, 20, 262144), // Desktop 3: turned off + entry(121, 1, 21, 262144 + 131072), // Desktop 4: ⌃⇧4 + entry(126, 1, 25, 524288), // Desktop 9: ⌥9 + '}', + ].join('\n') + expect(missionControlClashes(plist)).toEqual([ + { accel: 'Ctrl+1', owner: 'Mission Control: Switch to Desktop 1' }, + { accel: 'Ctrl+2', owner: 'Mission Control: Switch to Desktop 2' }, + { accel: 'Ctrl+Shift+4', owner: 'Mission Control: Switch to Desktop 4' }, + { accel: 'Alt+9', owner: 'Mission Control: Switch to Desktop 9' }, + ]) + }) + + it('reads nothing from an empty or unreadable answer', () => { + expect(missionControlClashes('')).toEqual([]) + expect(missionControlClashes('The domain/default pair does not exist')).toEqual([]) + }) +}) + +describe('[KS-03][ST-08] GNOME workspace keys', () => { + it('turns GNOME accelerators into bindings Orbit could have had', () => { + expect(gnomeAccelerator('Left')).toBe('Ctrl+Alt+Left') + expect(gnomeAccelerator('Page_Down')).toBe('Ctrl+Alt+PageDown') + expect(gnomeAccelerator('Right')).toBe('Ctrl+Alt+Shift+Right') + // Super is a key Orbit never binds; a bare key isn't a shortcut. + expect(gnomeAccelerator('Page_Up')).toBeUndefined() + expect(gnomeAccelerator('Left')).toBeUndefined() + }) + + it('lists every workspace key gsettings reports, with what it does', () => { + expect( + gnomeClashes({ + 'switch-to-workspace-left': "['Page_Up', 'Left']\n", + 'switch-to-workspace-right': "['Page_Down', 'Right']\n", + 'move-to-workspace-left': '@as []\n', + }), + ).toEqual([ + { accel: 'Ctrl+Alt+Left', owner: 'GNOME: Switch to workspace on the left' }, + { accel: 'Ctrl+Alt+Right', owner: 'GNOME: Switch to workspace on the right' }, + ]) + }) +}) From df416679f8e515ef4c954f9b07b95ef122edceaf Mon Sep 17 00:00:00 2001 From: Siya <57148521+siyabendoezdemir@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:31:20 +0000 Subject: [PATCH 06/11] =?UTF-8?q?feat(keys):=20Settings=20=E2=86=92=20Shor?= =?UTF-8?q?tcuts:=20search,=20record,=20conflicts,=20reset,=20priorities?= =?UTF-8?q?=20and=20Go=20to=20Tab=20options=20(ST-08)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings gets a Shortcuts section, added through the section registry. It lists every action main has registered, grouped, with a search by name, search words or keys. Click a key and press the new one: the router records it (keys.record), so Orbit's own keys run nothing while Settings listens. Esc cancels, Backspace removes the binding, + adds another (up to five). A key another action has opens a dialog offering Swap (when replacing) or Remove from the other action. Right-click an action for Reset to Default, Remove Shortcut and Prefer Websites / Prefer Orbit (KS-02). Reset All asks first. Also on the page: the Go to Tab options (Include Favorites, the last or ninth item on ⌘9), and keys the system keeps marked on their rows (KS-03). keys.available names the registered actions; settings.shortcuts opens the section from the command bar (KS-12). The right-click menu closes when the list under it moves, not on a late scroll event from a scroll made just before it opened. Co-Authored-By: Claude Opus 5.5 --- FEATURES.md | 10 +- docs/feature-status.json | 7 +- src/main/keys/bindings.ts | 11 +- src/main/keys/index.ts | 2 + src/renderer/src/keys/ShortcutsPage.tsx | 702 +++++++++++++++++++ src/renderer/src/keys/live.ts | 11 + src/renderer/src/keys/shortcut-list.ts | 139 ++++ src/renderer/src/keys/shortcuts.css | 209 ++++++ src/renderer/src/keys/shortcuts.settings.tsx | 13 + src/renderer/src/shortcuts.ts | 5 +- src/shared/commands/keys.ts | 2 + src/shared/commands/settings.ts | 8 + src/shared/keymap.ts | 3 + tests/e2e/keys.spec.ts | 157 +++++ tests/unit/keymap-settings.test.ts | 84 +++ 15 files changed, 1350 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/keys/ShortcutsPage.tsx create mode 100644 src/renderer/src/keys/shortcut-list.ts create mode 100644 src/renderer/src/keys/shortcuts.css create mode 100644 src/renderer/src/keys/shortcuts.settings.tsx create mode 100644 tests/unit/keymap-settings.test.ts diff --git a/FEATURES.md b/FEATURES.md index f538ab79..08eab06a 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -4,7 +4,7 @@ Every row is one behaviour from the clean-room spec. Status per ID: **spec’d** (every row) → **built** → **tested** (a unit or e2e test names the ID) → **feels right** (lead sign-off after a hands-on review against `feel.md`). -**Totals:** 475 spec’d · 205 built (75 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. +**Totals:** 475 spec’d · 205 built (76 partial) · 291 tested · 0 feel right. 10 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| @@ -346,9 +346,9 @@ Spec: `spec/shortcuts.md` | ID | Feature | P | Built | Tested | Feels right | Notes | |---|---|---|---|---|---|---| -| KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08 | -| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | -| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08 | +| KS-01 | One keymap, remapping and persistence | P0 | 🟡 partial | ✅ e2e+unit | – | one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; the shortcut sheet (OB-12) to come | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet | +| KS-03 | Platform mapping rules | P0 | 🟡 partial | ✅ e2e+unit | – | every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion | | KS-04 | Command bar and address | P0 | ✅ | ✅ e2e+unit | – | ⌘T/⌘L/Alt+D, Esc, ⌘T/⌘L again, ↑/↓ (⌃N/⌃P), Enter, Tab, ⌫, ⇧Enter (option), ⌘⌥⌫ / Shift+Delete, ⌘⌥V | | KS-05 | Tabs and windows | P0 | 🟡 partial | ✅ e2e+unit | – | ⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built | | KS-06 | Switching tabs | P0 | ✅ | ✅ e2e+unit | – | ⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab (⌃` on macOS) step through recent tabs, W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05) | @@ -542,7 +542,7 @@ Spec: `spec/settings.md` | ST-05 | Per-profile: auto-archive timing | P1 | ✅ | ✅ e2e+unit | – | 12 hours (default), 24 hours, 7 days, 30 days per profile; no Never; other profiles follow Default until set | | ST-06 | Per-profile: default search engine and custom engines | P0 | ✅ | ✅ e2e+unit | – | per-profile default engine (8 built-ins), custom engines with keyword and %s URL, Make default from the row menu; no extension override in Electron, so that row does not exist | | ST-07 | Per-profile: download location, new-document app, data shortcuts | P1 | – | – | – | | -| ST-08 | Shortcuts section (remapping) | P1 | – | ✅ e2e+unit | – | | +| ST-08 | Shortcuts section (remapping) | P1 | 🟡 partial | ✅ e2e+unit | – | Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action | | ST-09 | Links section | P1 | ✅ | ✅ e2e+unit | – | links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options | | ST-10 | Advanced section | P1 | 🟡 partial | ✅ e2e+unit | – | tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet | | ST-11 | Appearance section | P1 | 🟡 partial | ✅ e2e | – | Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2) | diff --git a/docs/feature-status.json b/docs/feature-status.json index 07a1df12..42eb15f8 100644 --- a/docs/feature-status.json +++ b/docs/feature-status.json @@ -137,10 +137,10 @@ "KS-07": {"built": "yes", "note": "Space shortcuts wired through the action table"}, "KS-19": {"built": "partial", "note": "the open overlay takes its keys first: command bar (⌘T/⌘L/⌘W/Esc), find bar (Enter/⇧Enter/Esc, NV-05), Peek (Esc, ⌘O, ⌘W, PK-09; Esc closes the Peek and the tab under it keeps loading), the recent-tab switcher (Tab/⇧Tab/`, W, letting go of ⌃), permission prompt and site controls (Esc), the sidebar with keyboard focus (arrows, Enter, Esc back to the page, SB-22); Esc then stops a loading page, as before, even one that uses Esc itself: Electron runs no isolated-world script in a page while it loads, so Orbit can't learn in time whether the page used the key (needs a lead decision: a script in the page's own world or a preload channel, D40)"}, "KS-16": {"built": "yes", "note": "typed keys never go astray: a page or Orbit view that loads (a slow page, a background tab, a reload) no longer takes the keyboard from the command bar, a sidebar field or the page in front (focus-on-navigation off for every web contents Orbit makes); ⌘←/⌘→ act outside text fields only; ⌘[ / ⌘] (Alt+←/→) from a page's text field go back or forward only when the page leaves them alone (a press into a frame Orbit can't see into stays the page's); ⌘Z in a page field is the page's undo (tabs lane, tested here); no shortcut on a plain key runs while an input method composes (unit test: e2e can't drive a composition through the key router)"}, - "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); the Settings → Shortcuts page comes with ST-08"}, - "KS-02": {"built": "partial", "note": "who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); keys.setPriority changes it (the Settings → Shortcuts option comes with ST-08); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, + "KS-01": {"built": "partial", "note": "one keymap keyed by action id (src/shared/actions.ts + src/shared/keymap.ts): the key router, macOS menu bar (rebuilt on a remap), tooltips and command-bar rows read the live bindings; remap, several bindings per action (up to 5), none (command bar and menus still run it), a modifier required except F1-F12, system keys refused, conflicts reported (clear or swap), reset; stored per device in settings.shortcuts, cleaned on load, kept across a restart (Space keys too); Settings → Shortcuts (ST-08) lists every action with a search, records new keys on a click and resets on right-click; the shortcut sheet (OB-12) to come"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: Toggle Sidebar (Cmd+S / Ctrl+S, SB-11) prefers websites on every platform, Copy URL (Ctrl+Shift+C, NV-17) on Windows/Linux, every other action prefers Orbit (the page never sees the key); with prefer websites the page's main frame gets the first press: left alone, the action runs; used (preventDefault), a toast offers the action and a second press within a second runs it; a held key's repeats stay the page's; a press into a frame Orbit can't see into counts as used; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page, so the page can't say in time what it did); Settings → Shortcuts sets it per action (right-click → Prefer Websites / Prefer Orbit; a “Websites first” tag on the row); Esc leaving page full screen keeps Chromium's rule and nothing holds Cmd+E yet"}, "KS-18": {"built": "partial", "note": "button tooltips (address, copy link, site controls, sidebar toggle, back/forward/reload, find, zoom, Library, Clear, Space dots, Peek, Mini Window Open in…), the empty-window hint, command-bar rows and macOS menu items follow a remap without a restart; context menus and the shortcut sheet (OB-12) to come"}, - "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); the Linux desktop-clash hint and the macOS Mission Control hint come with ST-08"}, + "KS-03": {"built": "partial", "note": "every default checked against the spec tables on macOS, Windows and Linux (unit test): ⌘ → Ctrl, ⌥ → Alt, ⌃ keys (⌃Tab, the split keys) stay Ctrl; the Windows/Linux exceptions Alt+1–9 for Spaces, Ctrl+H, Alt+←/→, Alt+D, F5, F3, Ctrl+F4 and F1; Alt+F4 and Alt+F are kept for the system; a Ctrl+Alt key that typed an AltGr character acts only outside text fields; letters and symbols follow the layout; F11 / Alt+Shift+F come with full screen (KS-08); keys the system keeps (macOS Mission Control “Switch to Desktop N” ⌃1–⌃9 when turned on, GNOME’s workspace keys) are marked in Settings → Shortcuts, which says where to turn them off; no first-run suggestion"}, "KS-05": {"built": "partial", "note": "⌘N, ⌘⇧N, ⌘⌥N, ⌘W / Ctrl+W / Ctrl+F4, ⌘⇧W / Ctrl+Shift+W (closes the window, or the Mini Window, Settings or onboarding window that has focus; Alt+F4 is the system's), ⌘⇧T (a closed tab comes back in its place), ⌘D (pins; again, back to the top of Today), ⌘⇧C, ⌘⇧⌥C, ⌘S, ⌘⇧K, ⌘⇧E, F6 (keyboard between the sidebar and the page), tested from a page; Duplicate Tab, Reset Tab, Rename Tab (shows a hidden sidebar to edit the name), Archive Tab, Move Tab to Space… (the command bar on its Move to rows) and Favorite This Tab have no keys until the user gives them some; ⌘Z (sidebar undo) is not remappable yet; the blank window (⌃⌘N, TB-17) and the top toolbar (⌘⇧D, SB-17) are not built"}, "KS-06": {"built": "yes", "note": "⌘1–⌘8 / Ctrl+1–8 (Favorites counted first unless turned off), ⌘9 / Ctrl+9 the last item or, set so, the ninth, ⌘⌥↓/↑ / Ctrl+Alt+↓/↑ and the ⌘⇧] / ⌘⇧[ (Ctrl+PgDn / Ctrl+PgUp) aliases, all tested from a page; while ⌃ is held, Tab and ⇧Tab (⌃` on macOS) step through recent tabs, W closes the highlighted tab (the next one in the switcher takes its place) and letting go settles; the switcher has no on-screen list yet (TB-05)"}, "FL-13": {"built": "yes", "note": "strip of pages, 1:1 tracking, commit thresholds, settle/rubber-band springs, one-page slides for clicks and keys, reduced motion cross-fade"}, @@ -263,6 +263,7 @@ "ST-20": {"built": "partial", "note": "per-profile rows carry the profile's name; download location, passwords and site permissions don't exist yet"}, "AP-01": {"built": "yes", "note": "Light / Dark / Match system in Settings and the Space theme picker; about 200 ms cross-fade, no flash"}, "OB-02": {"built": "yes", "note": "audit and e2e: no account UI anywhere, onboarding included; a first run sends nothing off the computer, with or without onboarding, except Chromium's spell-check dictionary download (see tests)"}, + "ST-08": {"built": "partial", "note": "Settings → Shortcuts: every action Orbit has (the action table, as main registers it) in groups with a search by name, search words or keys; click a key, press the new one (a modifier first except F1–F12; system and reserved keys refused, with the reason); + adds another (up to 5), ⌫ while listening removes one, Esc cancels; a key another action has asks: Swap (when replacing) or Remove from the other; right-click: Reset to Default, Remove Shortcut, Prefer Websites / Prefer Orbit (KS-02); Reset All asks first; Go to Tab: Include Favorites in ordering and ⌘9 last or ninth item; actions with no keys (Duplicate Tab…) show Record Shortcut; keys the system keeps are marked (KS-03); Settings search finds rows; stored per device. Not built: the Esc → exit full screen row, which comes when full screen (NV-20) registers that as an action"}, "ST-09": {"built": "yes", "note": "links from other apps open in a Mini Window (default), the most recent Space or one Space; routing rules (address contains / is exactly → a Space; first match wins; add, edit, reorder, remove); Peek options"}, "ST-10": {"built": "partial", "note": "tab sleep timer (TB-16), picture in picture, graphics acceleration; no site customizations, full-URL toggle, sounds or haptics, or edge-drag settings because those features don't exist yet"}, "ST-12": {"built": "partial", "note": "privacy promise (Orbit sends nothing); Clear browsing data per profile (⌘⇧⌫ / Ctrl+Shift+Delete): history by time range; cookies and site data, and the cache, always from all time (Electron has no range), so they start ticked only for All time; Secure DNS (Automatic, Off, Cloudflare, Google, Quad9); graphics acceleration lives in Advanced. No HTTPS-first or Safe Browsing (not in Electron), no per-site clearing here"}, diff --git a/src/main/keys/bindings.ts b/src/main/keys/bindings.ts index 8e5d2317..094eeb93 100644 --- a/src/main/keys/bindings.ts +++ b/src/main/keys/bindings.ts @@ -7,7 +7,13 @@ import { defaultShortcutsFor, } from '../../shared/actions' import type { BindRequest, BindResult, ShortcutPriority } from '../../shared/commands/keys' -import { bindingProblem, type CustomBindings, canonicalAccelerator, formatAccelerator } from '../../shared/keymap' +import { + bindingProblem, + type CustomBindings, + canonicalAccelerator, + formatAccelerator, + MAX_BINDINGS, +} from '../../shared/keymap' import type { Settings } from '../../shared/state' /** @@ -16,8 +22,7 @@ import type { Settings } from '../../shared/state' * next one, which src/main/keys/index.ts saves. */ -/** Most bindings one action keeps (KS-01: aliases such as Back = ⌘[ and ⌘←). */ -export const MAX_BINDINGS = 5 +export { MAX_BINDINGS } type Bindings = Record diff --git a/src/main/keys/index.ts b/src/main/keys/index.ts index 9d975823..1108ed0a 100644 --- a/src/main/keys/index.ts +++ b/src/main/keys/index.ts @@ -1,3 +1,4 @@ +import { ACTIONS } from '../../shared/actions' import type { BindResult, ShortcutPriority } from '../../shared/commands/keys' import { onKeymapChange, setCustomBindings } from '../../shared/keymap' import type { MainContext } from '../context' @@ -73,6 +74,7 @@ export function registerKeys(ctx: MainContext): void { if (typeof lastTabOnCmd9 === 'boolean') d.settings.lastTabOnCmd9 = lastTabOnCmd9 }), ) + commands.register('keys.available', () => ACTIONS.filter((a) => ctx.actions.has(a.id)).map((a) => a.id)) commands.register('keys.systemClashes', () => systemClashes()) registerWindowKeys(ctx) registerTabKeys(ctx) diff --git a/src/renderer/src/keys/ShortcutsPage.tsx b/src/renderer/src/keys/ShortcutsPage.tsx new file mode 100644 index 00000000..8dac789e --- /dev/null +++ b/src/renderer/src/keys/ShortcutsPage.tsx @@ -0,0 +1,702 @@ +import { Check, Lock, Plus, Search, X } from 'lucide-react' +import { + memo, + type MouseEvent as ReactMouseEvent, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import { ACTIONS, ACTIONS_BY_ID, formatAccelerator, shortcutsFor } from '../../../shared/actions' +import type { BindingConflict, BindResult, RecordedKey, ShortcutPriority } from '../../../shared/commands/keys' +import { acceleratorFromInput, MAX_BINDINGS, modifiersOf } from '../../../shared/keymap' +import { invoke } from '../api' +import { Button, Dialog, Select, SettingGroup, SettingRow, Switch } from '../settings/controls' +import { useMainValue } from '../settings/hooks' +import { useOrbit } from '../store' +import { setRecordingKeys, useKeymap } from './live' +import { + clashesOf, + entryMatches, + formatModifiers, + type ShortcutEntry, + searchWords, + shortcutGroups, +} from './shortcut-list' +import './shortcuts.css' + +const platform = window.orbit.platform +const mac = platform === 'darwin' +const keys = (accel: string) => formatAccelerator(accel, platform) + +/** A binding being recorded: a new one for `actionId`, or one in place of `replace`. */ +interface Recording { + actionId: string + replace?: string + /** Modifiers held now ("Cmd+Shift"), shown while the user builds a combination. */ + held: string + /** Tells one recording from the next, for answers that come back late. */ + session: number +} + +/** The keys asked for belong to another action: Settings asks what to do (ST-08). */ +interface Conflict { + actionId: string + accel: string + replace?: string + other: BindingConflict +} + +function bindKeys( + actionId: string, + accel: string, + replace?: string, + onConflict?: 'clear' | 'swap', +): Promise { + return invoke('keys.bind', { + actionId, + accel, + ...(replace ? { replace } : {}), + ...(onConflict ? { onConflict } : {}), + }) +} + +/** + * Recording a binding: main sends each key press in this window as `keys.recorded` and runs + * nothing while it records (src/main/keys/router.ts). Esc cancels, ⌫ / Delete removes the binding + * being changed; a press that makes a binding gives it to the action, or asks about a conflict. + */ +function useRecorder() { + const [recording, setRecording] = useState() + const [problem, setProblem] = useState<{ actionId: string; text: string }>() + const [conflict, setConflict] = useState() + const current = useRef(undefined) + const busy = useRef(false) + const sessions = useRef(0) + + const update = useCallback((next: Recording | undefined) => { + current.current = next + setRecording(next) + }, []) + + const stop = useCallback(() => { + if (!current.current) return + update(undefined) + setRecordingKeys(false) + void invoke('keys.record', { on: false }) + }, [update]) + + const start = useCallback( + (actionId: string, replace?: string) => { + const was = current.current + setProblem(undefined) + setConflict(undefined) + update({ actionId, ...(replace ? { replace } : {}), held: '', session: ++sessions.current }) + setRecordingKeys(true) + if (!was) void invoke('keys.record', { on: true }) + }, + [update], + ) + + const pressed = useCallback( + (key: RecordedKey) => { + const rec = current.current + if (!rec || busy.current) return + if (!key.accel) { + if (key.modifiers !== rec.held) update({ ...rec, held: key.modifiers }) + return + } + const accel = key.accel + if (accel === 'Escape') return stop() + if (accel === 'Backspace' || accel === 'Delete') { + if (rec.replace) void invoke('keys.unbind', { actionId: rec.actionId, accel: rec.replace }) + return stop() + } + busy.current = true + void bindKeys(rec.actionId, accel, rec.replace) + .then((result) => { + if (current.current?.session !== rec.session) return + if (result.ok) { + setProblem(undefined) + return stop() + } + if ('problem' in result) { + // Not a binding Orbit can use: say why and keep listening. + setProblem({ actionId: rec.actionId, text: result.problem }) + update({ ...rec, held: '' }) + return + } + stop() + setConflict({ + actionId: rec.actionId, + accel, + ...(rec.replace ? { replace: rec.replace } : {}), + other: result.conflict, + }) + }) + .finally(() => { + busy.current = false + }) + }, + [stop, update], + ) + + const active = recording !== undefined + useEffect(() => { + if (!active) return + const offMain = window.orbit.on('keys.recorded', (payload) => pressed(payload as RecordedKey)) + // Key presses main didn't take (synthetic input) are recorded the same way. + const fromPage = (e: KeyboardEvent) => { + e.preventDefault() + e.stopPropagation() + const input = { key: e.key, code: e.code, meta: e.metaKey, control: e.ctrlKey, alt: e.altKey, shift: e.shiftKey } + const accel = e.type === 'keydown' ? acceleratorFromInput(input) : undefined + pressed({ modifiers: modifiersOf(input), ...(accel ? { accel } : {}) }) + } + // A click anywhere but on the binding being recorded stops recording. + const onPointer = (e: MouseEvent) => { + if (!(e.target instanceof Element) || !e.target.closest('[data-recording]')) stop() + } + window.addEventListener('keydown', fromPage, true) + window.addEventListener('keyup', fromPage, true) + document.addEventListener('mousedown', onPointer, true) + window.addEventListener('blur', stop) + return () => { + offMain() + window.removeEventListener('keydown', fromPage, true) + window.removeEventListener('keyup', fromPage, true) + document.removeEventListener('mousedown', onPointer, true) + window.removeEventListener('blur', stop) + } + }, [active, pressed, stop]) + // Leaving the page (another section, a search, closing Settings) stops recording. + useEffect(() => stop, [stop]) + + const resolve = useCallback((c: Conflict, how: 'clear' | 'swap') => { + setConflict(undefined) + void bindKeys(c.actionId, c.accel, c.replace, how).then((result) => { + if (!result.ok && 'problem' in result) setProblem({ actionId: c.actionId, text: result.problem }) + }) + }, []) + + return { recording, problem, conflict, start, stop, resolve, dismiss: () => setConflict(undefined) } +} + +/** The first binding of an action as this platform shows it, or ''. */ +function firstKeys(actionId: string): string { + const action = ACTIONS_BY_ID[actionId] + const accel = action ? shortcutsFor(action, platform)[0] : undefined + return accel ? keys(accel) : '' +} + +/** + * Settings → Shortcuts (ST-08): every action with its keys, a search, recording new keys, + * conflicts, Reset to Default and the per-action priority on right-click (KS-02), and the Go to + * Tab options (TB-04, FV-10). + */ +export function ShortcutsPage() { + const version = useKeymap() + const priorities = useOrbit((s) => s.settings.shortcutPriority) + const lastTabOnCmd9 = useOrbit((s) => s.settings.lastTabOnCmd9) + const countFavorites = useOrbit((s) => s.settings.countFavoritesInShortcuts) + const [clashes] = useMainValue(() => invoke('keys.systemClashes', {})) + // Only actions Orbit has: the table also names some that no feature runs yet. + const [available] = useMainValue(() => invoke('keys.available', {})) + const [query, setQuery] = useState('') + const [menu, setMenu] = useState<{ actionId: string; x: number; y: number }>() + const [confirmReset, setConfirmReset] = useState(false) + const recorder = useRecorder() + const { recording, problem, conflict, start, stop } = recorder + + // biome-ignore lint/correctness/useExhaustiveDependencies: the keymap's version stands for the bindings + const groups = useMemo(() => { + if (!available) return [] + const has = new Set(available) + return shortcutGroups( + platform, + { ...(priorities ? { priorities } : {}), lastTabOnCmd9 }, + ACTIONS.filter((a) => has.has(a.id)), + ) + }, [version, priorities, lastTabOnCmd9, available]) + const words = searchWords(query) + const shown = groups + .map((g) => ({ ...g, entries: g.entries.filter((e) => entryMatches(e, words, platform)) })) + .filter((g) => g.entries.length > 0) + const entries = useMemo(() => new Map(groups.flatMap((g) => g.entries.map((e) => [e.action.id, e]))), [groups]) + const allDefault = groups.every((g) => g.entries.every((e) => e.isDefault)) + const clashing = groups.flatMap((g) => g.entries.flatMap((e) => clashesOf(e, clashes))) + + const goto = firstKeys('tab.goto.1') && firstKeys('tab.goto.8') + const range = goto ? `${firstKeys('tab.goto.1')}–${firstKeys('tab.goto.8')}` : 'Go to Tab 1–8' + const nine = firstKeys('tab.goto.9') || 'Go to Tab 9' + const favoritesText = `${range} count your Favorites first, then pinned and Today tabs.` + const lastText = 'The last item, or the ninth.' + const has = (text: string) => words.every((w) => text.toLowerCase().includes(w)) + const showFavorites = has(`Go to Tab Include Favorites in ordering ${favoritesText}`) + const showLast = has(`Go to Tab ${nine} goes to ${lastText}`) + const resetText = 'Reset all shortcuts' + const showReset = has(`${resetText} default`) + + const onContextMenu = (e: ReactMouseEvent) => { + const target = e.target as Element + if (target.closest('.ks-menu')) return + const row = target.closest('[data-setting]') + const actionId = row?.querySelector('[data-shortcut-action]')?.dataset.shortcutAction + const entry = actionId ? entries.get(actionId) : undefined + if (!entry || entry.action.fixed) return + e.preventDefault() + stop() + // From the keyboard (⇧F10, the menu key) the menu opens at the row's keys. + let { clientX: x, clientY: y } = e + if (x === 0 && y === 0) { + const rect = (row?.querySelector('[data-shortcut-action]') ?? target).getBoundingClientRect() + x = rect.left + y = rect.bottom + } + setMenu({ actionId: entry.action.id, x, y }) + } + + const menuEntry = menu ? entries.get(menu.actionId) : undefined + + return ( +
+ {/* .st-groups with no rows: Settings search (ST-02), which searches every page, hides them. */} +
+ +
+
+

+ Click a shortcut, then press the new keys. Right-click an action to reset it, or to choose whether websites or + Orbit get its keys first. +

+ {clashing.length > 0 && ( +

+ {mac + ? 'macOS keeps some of these keys for itself, so they never reach Orbit (marked below). Turn them off in System Settings → Keyboard → Keyboard Shortcuts, or give those actions other keys here.' + : 'Your desktop keeps some of these keys for itself, so they never reach Orbit (marked below). Change them in your desktop’s keyboard settings, or give those actions other keys here.'} +

+ )} +
+ + {(showFavorites || showLast) && ( + + {showFavorites && ( + + void invoke('keys.setOptions', { countFavorites: next })} + /> + + )} + {showLast && ( + +