diff --git a/FEATURES.md b/FEATURES.md index ff371cfc..7b4aba7a 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:** 472 spec’d · 227 built (80 partial) · 316 tested · 0 feel right. 13 more are out of scope and not counted. +**Totals:** 472 spec’d · 237 built (86 partial) · 330 tested · 0 feel right. 13 more are out of scope and not counted. | Priority | Spec’d | Built | Tested | |---|---|---|---| -| P0 | 174 | 135 | 161 | -| P1 | 219 | 77 | 138 | +| P0 | 174 | 140 | 168 | +| P1 | 219 | 82 | 145 | | P2 | 79 | 15 | 17 | P0 = milestone-1 core experience, P1 = parity, P2 = later. @@ -27,10 +27,10 @@ Spec: `spec/sidebar.md` | SB-05 | Favorites grid placement | P0 | ✅ | ✅ e2e | – | Favorites between the address and the title row, outside the scrolling list; same grid, same place across Spaces of one profile | | SB-06 | Space title row placement | P0 | ✅ | ✅ e2e | – | title row: inline rename, hover profile badge and … menu (pinned caret is PN-09) | | SB-07 | "+ New Tab" row | P0 | ✅ | ✅ e2e | – | + New Tab row with press animation opens the command bar | -| SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | – | – | | +| SB-08 | Empty sidebar area: double-click, drag, right-click | P1 | – | ✅ e2e | – | | | SB-09 | Bottom bar: Library, Space switcher, "+" menu | P0 | ✅ | ✅ e2e | – | the Library button (a click opens the Library; a 300 ms hover shows the recent files, downloads and screenshots, which open on a click and drag out as the file; right-click chooses Downloads or Screenshots), the Space switcher and the + menu; the Library and + buttons are 28×28. New Note and New Canvas join the + menu with the notes and canvases lanes | | 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; on Windows and Linux a page that uses Ctrl+S itself gets the first press, and a second press within a second or the toast's button toggles the sidebar; on macOS ⌘S toggles at once, as every action there prefers Orbit (D60; Settings → Shortcuts can change either, 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, the command bar is open or a native menu is up in the window (one Chromium refuses counts as closed); 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 | @@ -44,7 +44,7 @@ Spec: `spec/sidebar.md` | SB-22 | Keyboard focus and navigation inside the sidebar | P2 | ✅ | ✅ e2e | – | Cmd/Ctrl+Shift+E focuses the sidebar; arrows, Enter, Escape, F2 | | SB-23 | Scrolling, auto-reveal of the active tab and overflow indicator | P1 | ✅ | ✅ e2e | – | selected tab scrolled into view (smooth, then settles); pill points to it while it is out of view; rows use content-visibility | | SB-24 | Sidebar colouring follows the Space theme | P0 | 🟡 partial | ✅ e2e+unit | – | theme gradient + grain on sidebar and frame, tinted selection; the neutral theme is a solid soft grey/near-black, not a translucent material yet | -| SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | +| SB-25 | Incognito window sidebar | P1 | ✅ | ✅ e2e+unit | – | private window sidebar: top bar and address, a Private label with icon and a plain note, then only its own tabs; no Spaces, Favorites or pinned tabs (and their actions do nothing); + and Cmd/Ctrl+T open the command bar, which a new private window starts with | | SB-26 | Platform notes (Windows / Linux) | P2 | – | – | – | | | SB-27 | Link status pill | P1 | ✅ | ✅ e2e+unit | – | hovering (or focusing) a link shows a theme-styled pill with where it goes (no scheme) at the bottom-left of that page card, in its own small view; the full URL after 1.5 s on the link, kept for the next links while it stays up; moves to the other corner when the pointer comes within 24 px, out of the way if near both; goes 120 ms after the link is left; not on Peek pages | | SB-28 | Page loading indicator | P1 | ✅ | ✅ e2e | – | loading indicator at the top centre of the shown page card (the focused pane; Mini Windows too), in its own view over the page, after 150 ms; it leaves the window when hidden; shows in native full screen, not over a page in HTML full screen (back when that ends if still loading); tab rows keep their favicon loading ring | @@ -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); Ctrl+` steps back too, on every platform, while the switcher is up (D60); 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 | @@ -231,7 +231,7 @@ Spec: `spec/navigation.md` | NV-01 | Back and forward | P0 | ✅ | ✅ e2e+unit | – | buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert | | NV-02 | Trackpad two-finger swipe back/forward | P1 | – | – | – | | | NV-03 | Reload and hard reload | P0 | ✅ | ✅ e2e+unit | – | Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page | -| NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes | +| NV-04 | Stop loading | P1 | ✅ | ✅ e2e+unit | – | Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes | | NV-05 | Find in page | P0 | ✅ | ✅ e2e+unit | – | floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11) | | NV-06 | Page zoom | P0 | ✅ | ✅ e2e+unit | – | Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too | | NV-07 | Print | P1 | – | – | – | | @@ -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 | – | | @@ -346,26 +346,26 @@ 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-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-01 | One keymap, remapping and persistence | P0 | ✅ | ✅ 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; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now | +| KS-02 | Conflicts with web pages ("prefer websites" vs "prefer Orbit") | P0 | 🟡 partial | ✅ e2e+unit | – | who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page 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; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); 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 step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), 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 | – | – | – | | -| 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 | – | ✅ e2e+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 | ✅ | ✅ e2e+unit | – | ⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs) | +| 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 | 🟡 partial | ✅ e2e+unit | – | ⌘Y / Ctrl+H View History (AR-10); ⌘⇧L Open Library on macOS, the command bar elsewhere; View Archive with no key, assignable; ⌘, / Ctrl+, Settings, which cannot be remapped, and again brings the same Settings window to the front; ⌘⇧⌫ / Ctrl+Shift+Delete Clear Browsing Data…; Shortcut Settings (command bar, assignable); the shortcut sheet from Help → Keyboard Shortcuts (macOS) and the command bar (OB-12); Open File… (macOS File menu, the command bar) and ⌘O / Ctrl+O wherever no Peek or Mini Window is open: the files chosen open in new tabs, the last one selected (Settings → Shortcuts says so under Open File…, which can also get keys of its own); with no Orbit window shown, the macOS menu bar takes ⌘O itself, so Open File… still opens (F6); Alt+F stays free for the Windows/Linux app menu (SB-03, SB-26, not built). Not built: Open Downloads (DL-14, downloads lane), pinned extensions (EX-06) | +| KS-13 | Developer tools | P1 | 🟡 partial | ✅ e2e+unit | – | ⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc) | | 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-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 | -| KS-20 | Shortcuts in special windows | P1 | – | – | – | | +| KS-15 | Standard app and window keys | P1 | ✅ | ✅ e2e+unit | – | macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac | +| 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+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘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 | 🟡 partial | ✅ e2e | – | each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09) | +| KS-18 | Shortcut hints everywhere | P1 | ✅ | ✅ e2e+unit | – | 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, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label | +| KS-19 | Keys inside overlays | P0 | 🟡 partial | ✅ e2e+unit | – | 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); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07) | +| KS-20 | Shortcuts in special windows | P1 | 🟡 partial | ✅ e2e+unit | – | private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10) | ## Interaction feel and motion targets (prefix FL) @@ -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 | 🟡 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, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + 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) | @@ -598,8 +598,8 @@ Spec: `spec/onboarding.md` | OB-09 | Done screen and the first browser window | P1 | 🟡 partial | ✅ e2e | – | Done screen sums up colours, Favorites, default browser and import (what will be added from where, e.g. "6 bookmarks from bookmarks.html", or that nothing was and where import lives); Start browsing (Back to browsing after a reset) opens the first window centred at about 86% of the screen with the colours and Favorites picked, command bar closed. No Orbit basics folder or local welcome tab (no local guide pages yet) | | OB-10 | Getting-started guide | P1 | – | – | – | | | OB-11 | Contextual tips and one-time hints | P1 | – | – | – | | -| OB-12 | Essential keyboard shortcuts sheet | P1 | – | – | – | | -| OB-13 | Help and feedback entry points | P1 | 🟡 partial | ✅ e2e+unit | – | command-bar Help, Report a Bug, What's New and Copy Build Info; macOS Help menu (Orbit Help, Report a Bug…, Release Notes, Troubleshooting → Copy Build Info). Help opens the project README; Report a Bug opens a new issue on the public tracker with the build info filled in. The shortcut sheet entry comes with OB-12 (keys lane) | +| OB-12 | Essential keyboard shortcuts sheet | P1 | ✅ | ✅ e2e+unit | – | Help → Keyboard Shortcuts (macOS Help menu; the command bar’s Keyboard Shortcuts everywhere; a key can be assigned) opens a sheet over the window with the essential keys as they are now (KS-18), grouped Everyday, Navigation, Spaces and Split View, “None” for an action with no key; Esc, ✕, a click outside, Close Tab’s keys or asking again close it; the link at the bottom opens Settings → Shortcuts. Asked from Settings or a Mini Window, it opens over the browser window behind (a new one if there is none) | +| OB-13 | Help and feedback entry points | P1 | ✅ | ✅ e2e+unit | – | command-bar Help, Report a Bug, What's New and Copy Build Info; macOS Help menu (Orbit Help, Report a Bug…, Release Notes, Troubleshooting → Copy Build Info). Help opens the project README; Report a Bug opens a new issue on the public tracker with the build info filled in. Help → Keyboard Shortcuts (macOS) and the command bar’s Keyboard Shortcuts open the shortcut sheet (OB-12, keys lane) | | OB-14 | Skip, resume and reset onboarding | P2 | ✅ | ✅ e2e+unit | – | Skip setup on every step but the last gives the spec's default first window: the theme step's colours and mode are undone and no Favorites are added (D44). The step, the theme and the Favorites picked so far are saved, so a quit part-way resumes there with them. An import chosen at the import step isn’t: it waits in memory for Start browsing, so a quit before then loses it, and the Done step says nothing was imported (it’s in Settings, under General); Reset Onboarding (command bar only) runs the flow again over the open windows and deletes nothing | | OB-15 | AI features opt-in step | OOS | ⛔ out of scope | – | – | the spec leaves it out | | OB-16 | Personal welcome card | P2 | – | – | – | | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b4a356ab..456183bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -38,6 +38,25 @@ BrowserWindow (frameless; hiddenInset traffic lights on macOS) - **Keyboard shortcuts** are matched in main via `before-input-event` on every Orbit-owned web contents **and** every tab, so they work whatever has focus. The renderer has a fallback (`src/renderer/src/shortcuts.ts`). The final shortcut set follows `spec/shortcuts.md` (KS-*). Remapping (ST-08) layers user overrides over the defaults. - **Hooks** (`MainContext.hooks`) let features plug into tab creation, navigation and `window.open` without editing core files (Peek and Mini Window take over link opens this way). +### Keys: routing, pages and the keyboard (`src/main/keys/`) + +- **Router order.** `WindowManager.attachShortcuts` (`src/main/windows.ts`) asks the keys lane's `keyRouter` (`src/main/keys/router.ts`) about each key press, in this order: + 1. `record`: while Settings → Shortcuts listens for a new binding in that window, the press is reported (`keys.recorded`) and runs nothing (ST-08). + 2. `switcherKey`: while the recent-tab switcher is up (⌃Tab held), ⌃W closes the highlighted tab and ⌃` steps back (KS-06, TB-05, D60). + 3. `actionForInput`, then `actionIn`: the action the binding runs where it was pressed (⌘O opens a file outside a Peek or Mini Window, KS-12). + 4. No action, or none registered: `runOutsideText` (on macOS ⌘← and ⌘→ go back and forward only outside text fields, KS-16). + 5. `leaveToPage`: keys a page may use go to it first (below). + 6. Otherwise the key is kept from the page (`preventDefault`) and the action runs, with `repeat` for a held key. Interceptors (private and Mini Windows, the shortcut sheet, DevTools) may answer it there instead. +- **Pages first** (KS-02, KS-16). An action that prefers websites, and Back and Forward in a text field, go to the page first. The **page watcher** (`page-keys.ts`) is a script in isolated world 1020 of each tab's main frame, installed at `dom-ready`. For each trusted key-down it notes, once every handler of the page has run, whether one called `preventDefault` and whether a text field had focus. Its answers mean: + - `{ seen: true, prevented, inField }`: what the page did; + - `{ seen: false }`: the press didn't reach it within 200 ms, so a handler of the page that ran first stopped it; + - nothing within 250 ms (`ANSWER_WAIT_MS`: a busy renderer, or a page Orbit can't script): the page left the key alone, and the action runs (D60). + + Electron runs no isolated-world script while a main frame loads, so a page on its way can't answer; Orbit takes the keys at once then. +- **Focus mirror** (`page-focus.ts`, D60). Main knows before a press where the keyboard is in each tab's page: a script in the page's own world says whether a text field, the page, a frame inside it, or nothing there has it, and says it again when focus moves. The frame inside the page that has the keyboard (`contents.focusedFrame`) is followed too, and notes the presses Orbit may ask about. So Back and Forward run at once outside text fields, however busy the page, and a press in a frame is judged by that frame. +- **Esc on a loading page** (KS-19, D59, `escape.ts`): the page gets Esc first. The page preload says whether a handler used it, and Orbit stops the load only if none did. +- **No web contents takes the keyboard on navigation** (`KEEP_KEYBOARD`, `focus.ts`, KS-16). Electron focuses a web contents each time its page commits a navigation unless `focusOnNavigation` is off, even a page behind another tab. Every web contents Orbit makes for its windows spreads `KEEP_KEYBOARD` into its `webPreferences`: tab views, Orbit's own views, and pop-ups through `overrideBrowserWindowOptions`. Orbit gives a page the keyboard itself when the user selects, opens or clicks it. A lane that makes or shows a page does the same. + ## 4. Tabs: lifecycle, sleeping, layout - A tab exists in state whether or not it is **awake**. `TabRuntime.ensure(tabId)` creates its `WebContentsView` lazily (sandboxed, per-profile session) and restores saved navigation history if the tab slept. `sleep(tabId)` saves history and closes the renderer process. That's how memory per idle tab stays low (TB-16). Sleeping is driven by `settings.sleepAfterMs`. Favorites stay warm (FV-08) and audible/visible tabs never sleep. @@ -80,6 +99,7 @@ src/main/ main process media/ PiP, mini player lane: peek-mini (media) tweaks/ extensions/ lane: tweaks-extensions history/ browsing-history store (per-profile JSONL + index) lane: commandbar (query API used by archive) + keys/ keymap, key router, page watcher, focus mirror lane: keys archive/ lane: archive-persistence settings/ import/ downloads/ updates/ lane: settings-import src/shared/ types and pure logic shared by main+renderer (state, tree, url, theme, actions, commands/.ts per lane) @@ -124,6 +144,9 @@ Sandboxed, context-isolated renderers everywhere. No Node in pages. Pages get on - `orbit-page:permission` (NV-15): a synchronous read of how a permission reads for the sending frame's own origin and profile ("prompt" while the user hasn't decided). Read-only. - `orbit-page:notification-click` (NV-15): a trusted click on one of the page's notifications. Main brings the tab forward only for a site allowed to show notifications, and at most once per `NOTIFICATION_CLICK_GAP_MS` per tab. - `orbit-page:internal-*` (DT-13, D53): Orbit's internal pages only. In the main frame of an `orbit:` page, which is Orbit's own UI, the preload patches nothing and gives `window.orbit`: the store's state and changes, and the commands listed for that page (`INTERNAL_PAGE_COMMANDS`), with no events. Main answers only a tab's main frame showing an `orbit:` page. In a web page it does nothing. +- `orbit-page:escape` (KS-19, D59): one bit after a trusted Esc reaches the main frame, sent once every handler of the page has run: whether one called `preventDefault`. The listener is in the preload's own world. Main reads the bit only while it waits after an Esc in a loading tab with no overlay open (at most 250 ms; no answer counts as not used), only from that tab's main frame, and only for a press still waiting: a bit with none waiting does nothing. A page that always uses Esc can keep Esc from stopping its load; the Stop button still stops it. + +Main also runs scripts in pages, as media, Peek and nav do: the keys lane's page watcher in isolated world 1020, and its focus mirror in the page's own world (`src/main/keys/page-focus.ts`), which leaves nothing behind in the page. What they say is the page's own word: main checks each answer's shape, and an answer can only change what that page's own keys do. The UI's `orbit:*` IPC accepts registered Orbit UI senders only. UI renderers can't navigate or open windows. Permission prompts are per origin. Tweak JS (user-authored, per site) is injected only through `webFrame.executeJavaScript` in an isolated world, never with Node. Extensions use Electron's built-in extension support plus Orbit's own (MIT) API shims (D3: no GPL dependency). diff --git a/docs/feature-status.json b/docs/feature-status.json index 8fceaa53..42804f9a 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": "yes", "note": "the Library button (a click opens the Library; a 300 ms hover shows the recent files, downloads and screenshots, which open on a click and drag out as the file; right-click chooses Downloads or Screenshots), the Space switcher and the + menu; the Library and + buttons are 28×28. New Note and New Canvas join the + menu with the notes and canvases lanes"}, - "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; on Windows and Linux a page that uses Ctrl+S itself gets the first press, and a second press within a second or the toast's button toggles the sidebar; on macOS ⌘S toggles at once, as every action there prefers Orbit (D60; Settings → Shortcuts can change either, 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, the command bar is open or a native menu is up in the window (one Chromium refuses counts as closed); 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"}, @@ -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); Ctrl+` steps back too, on every platform, while the switcher is up (D60); 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"}, @@ -138,7 +138,15 @@ "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); then Esc on a loading page goes to the page first: the page's preload tells main whether the page used it, and Orbit stops the load only if it didn't, waiting 250 ms at most (no answer counts as left alone; D59, D60); only the main frame of a tab's page answers, and only for an Esc pressed there. Not built: leaving full screen with Esc held or pressed twice (NV-20), the print dialog (NV-07)"}, + "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+←/→) outside text fields go back or forward at once and the page never sees them, however busy it is (Orbit knows where the keyboard is before the press, D60); from a text field they go only when its page leaves them alone (no answer within 250 ms counts as left alone); with the keyboard in a frame, the frame's own fields and handlers decide (e2e with same-site frames; cross-site frames take the same path, but e2e keys can't reach their process); Esc in a Mini Window's address field gives the keyboard back to its page; ⌘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": {"built": "partial", "note": "each pointer binding checked against its feature's tests: ⌘-click or middle-click, ⇧⌘-, ⇧- (Peek), ⌥- (split view) and ⌘⌥-click (Mini Window) on links; ⌘-click or middle-click on Back, Forward (keys e2e) or Reload for a new tab; long-press or right-click Back / Forward for history; double-click Reload to stop; ⌘-click a pinned tab's favicon (PN-04); ⌘- / ⇧-click rows (TB-13); double-click the empty sidebar (keys e2e), a tab row (TB-12) or a Favorite (FV-06); swipe over the sidebar (SP-08); pinch / ⌘-scroll (NV-06); mouse buttons 4/5; right-click twice on a video (MD-02). Not built: ⌘-hover + New Tab to paste and go (CB-12), swipe over the page (NV-02), ⌥-click Capture full page (DT-09)"}, + "KS-01": {"built": "yes", "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; Help → Keyboard Shortcuts (OB-12) shows the essential keys as they are now"}, + "KS-02": {"built": "partial", "note": "who gets a key first, per action: on Windows and Linux Toggle Sidebar (Ctrl+S, SB-11) and Copy URL (Ctrl+Shift+C, NV-17) prefer websites; every other action, and on macOS every action, ⌘S included (D60), prefers Orbit (the page never sees the key); with prefer websites the page 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; with the keyboard in a frame, that frame says what it did; no answer within 250 ms counts as left alone (D60), so a busy page holds a key a quarter of a second at most; while a page's main frame loads, Orbit takes the keys at once (Electron runs no isolated-world script in a loading page); 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": "yes", "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, macOS menu items, the shortcut sheet (OB-12) and the right-click menus of a tab, a multi-selection, the page and a split pane (an item that does what a key does shows that key’s first binding) follow a remap without a restart; a menu shows it the next time it opens. Windows and Linux have no app menu yet (SB-03, SB-26), so there is no menu bar there to label"}, + "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 step through recent tabs and ` steps back too, on every platform and only while the switcher is up (D60), 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"}, @@ -181,7 +189,7 @@ "PR-09": {"built": "partial", "note": "profiles.rename command; the Settings UI is the settings lane's"}, "NV-01": {"built": "yes", "note": "buttons and keys (Cmd+Left/Right only outside text fields), history menu with favicons that closes on tab switch, Cmd/middle-click opens the entry in a background tab with its history; mouse side buttons are Chromium's; Backspace inert"}, "NV-03": {"built": "yes", "note": "Cmd/Ctrl+R, F5; hard reload Cmd+Shift+R, Ctrl+Shift+R, Shift+F5, Ctrl+F5; Cmd/middle-click Reload duplicates with history; keyboard focus goes to the page"}, - "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes"}, + "NV-04": {"built": "yes", "note": "Esc in the page while loading (overlays take it first; a page that uses Esc keeps it and goes on loading, D59), Cmd+. on macOS, double-click Reload, Stop (x) state; a typed address, Back/Forward or Reload shows where it is going at once and stays when stopped; the loading indicator goes"}, "NV-05": {"built": "yes", "note": "floating bar over the active page's top-right corner; finds as you type, n/m count, No matches; Enter/Cmd+G/F3 next, Shift+Enter/Shift+Cmd+G/Shift+F3 previous, buttons; Esc closes and keeps the match selected; per tab (hidden on switch, back with text and place); starts from the selection, else the last search; IME commits; keys typed for the bar before it has the keyboard (a new window's first moments, a busy bar) wait up to 5 s and then go into it in order, text as text (any character); it takes the keyboard when it shows, even later than that; shortcuts and IME composition are not held; Cmd+J jumps to the selection (macOS); Edit > Find menu; does nothing while a Peek is open, so the keyboard stays in it (finding in a Peek is PK-11)"}, "NV-06": {"built": "yes", "note": "Chromium steps 25-500% on Cmd+=/Cmd+-/Cmd+0 and Ctrl+wheel (Win/Linux), in a Peek (with a toast) and a Mini Window (no indicator there) too; per host in each profile (all its tabs), remembered across launches (written on quit), dropped with a deleted profile; indicator with Reset over the page for 1.5 s (longer while hovered); pinch is visual zoom and Cmd+0 undoes it too"}, "NV-08": {"built": "partial", "note": "Cmd/middle-click background tab (toast Opened in a new tab while the sidebar is hidden) and Shift+Cmd foreground tab at the top of Today; Shift-click a new window when Peek is off; the page itself opens these (a link click in an isolated world, with the gesture), so they are requested as its click is: Referer, Sec-Fetch-Site, no SameSite=Strict cookie across sites; Option/Alt-click split view instead of the engine download; a local file opens in a new tab, window, Mini Window or split view only for a local file page whose frames are all local too, whatever the engine lets through: Orbit can't tell which frame asked, so a frame of any other origin (a web page's, or an opaque one: sandboxed, data:) refuses Cmd-, Shift- and Cmd+Option-clicks, target=_blank and window.open, and a toast names the file; a plain click without a target still opens it (D49); Cmd+Option-click a Mini Window and Shift-click Peek through the peek lane; hover pill is SB-27; still requested as if typed (Sec-Fetch-Site none, Strict cookies sent), with the page as Referer: the page menu Open Link in New Tab and New Window, Option-click split view, a blocked pop-up Open; without a Referer too: Peek, Mini Window and routing-rule links (peek lane), a pop-up window own links, Open Link in Private Window (on purpose); a link in a frame from another origin is opened from the top page, without a Referer; not built: dragging links"}, @@ -192,8 +200,9 @@ "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"}, + "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"}, @@ -216,6 +225,11 @@ "MD-16": {"built": "partial", "note": "enter: the video shrinks from its place in the page to the corner (~300 ms, standard curve); Back to tab/double-click: the tab is selected at once and a still of the last frame flies back into the page; release glides to the corner; Back to PiP grows from the controller; tray slides with the snappy spring; with reduced motion (FL-28) the player appears and goes without motion and a release settles with a 120 ms ease-out; no haptic tick on macOS"}, "FL-32": {"built": "yes", "note": "corner resize follows the pointer, resists past its limits (rubber band) and springs back on release; a fling heads for the corner in its direction and settles with the spring; tray slides up with the snappy timing; Back to tab flies to the video's place; pinch zooms (MD-06) rather than resizes"}, "KS-11": {"built": "partial", "note": "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": {"built": "partial", "note": "⌘Y / Ctrl+H View History (AR-10); ⌘⇧L Open Library on macOS, the command bar elsewhere; View Archive with no key, assignable; ⌘, / Ctrl+, Settings, which cannot be remapped, and again brings the same Settings window to the front; ⌘⇧⌫ / Ctrl+Shift+Delete Clear Browsing Data…; Shortcut Settings (command bar, assignable); the shortcut sheet from Help → Keyboard Shortcuts (macOS) and the command bar (OB-12); Open File… (macOS File menu, the command bar) and ⌘O / Ctrl+O wherever no Peek or Mini Window is open: the files chosen open in new tabs, the last one selected (Settings → Shortcuts says so under Open File…, which can also get keys of its own); with no Orbit window shown, the macOS menu bar takes ⌘O itself, so Open File… still opens (F6); Alt+F stays free for the Windows/Linux app menu (SB-03, SB-26, not built). Not built: Open Downloads (DL-14, downloads lane), pinned extensions (EX-06)"}, + "KS-13": {"built": "partial", "note": "⌥⌘I / F12 (Ctrl+Shift+I / F12) open or close DevTools (DT-01, F12 in e2e), ⌥⌘J (Ctrl+Shift+J) the console, ⌥⌘C the element picker (no default on Windows/Linux, where Ctrl+Shift+C is Copy URL), ⌥⌘U (Ctrl+U) View Source, ⌘⇧R (Ctrl+Shift+R) hard reload: each checked against the spec table per platform, and none fixed; a remapped DevTools key moves in the router and in DevTools' own page, and the picker can take ⇧⌘C from Copy URL (unit tests); ⇧⌘M / Ctrl+Shift+M stays DevTools' device toolbar key (no action has it). A remap reaches DevTools already open only once they open again (devtools lane). Not built: developer mode for a site (DT-06; its ⌃D is a plain ⌃-letter, which macOS keeps for the caret, D60) and the task manager (DT-11, Shift+Esc)"}, + "KS-10": {"built": "yes", "note": "⌘O / Ctrl+O expands a Peek into a selected Today tab (PK-07); Esc and ⌘W / Ctrl+W close it (PK-09, Esc fixed); ⌘⇧T and ⌘Z reopen it (PK-10); ⌘⌥N / Ctrl+Alt+N opens a Mini Window (MW-06); in a Mini Window ⌘O moves its page into the most recent Space (MW-05), ⌘⌥O (also ⌘⇧O) / Ctrl+Alt+O offers the Spaces, ⌘W closes it (MW-09). All from the one keymap and remappable but Esc; the tests press the keys (peek, mini-window and keys specs)"}, + "KS-15": {"built": "yes", "note": "macOS: the menu bar keeps the system’s items and keys: Hide Orbit ⌘H, Hide Others ⌥⌘H and Quit ⌘Q in the Orbit menu, the Window menu (Minimize ⌘M, ⌘` between Orbit windows) and the Help menu with its search (⌘?). No action can take these keys, nor Alt+F4, Alt+Tab or Alt+F on Windows and Linux, and Settings → Shortcuts says what keeps them (KS-03). Windows and Linux: Alt+F4 closes the window and, on the last one, quits; F1 opens Help (OB-13) and can’t be changed there. An Exit item comes with the Windows/Linux app menu (SB-26, not built). The menu template and keymap are unit-tested; not run on a Mac"}, + "KS-20": {"built": "partial", "note": "private window: the same keys, but the actions that need Spaces, pinned tabs, Favorites, folders or the Archive (⌘D, ⌃1–9 / Alt+1–9, ⌘⌥← / ⌘⌥→, ⌘⇧K Clear, Favorite, New Folder, View Archive, every Space action) do nothing there, from a key, a menu or the command bar, and a toast says “Not available in a private window”; its sidebar has no Clear button; tab keys work. Mini Window: ⌘W closes it, ⌘O / ⌘⌥O move or offer to move its page (KS-10), ⌘L edits its address with the keyboard in the field and Esc gives the keyboard back to its page, ⌘R, zoom and print act on its page, sidebar and Space keys do nothing; ⌘Y / Ctrl+H History, the Library and Help → Keyboard Shortcuts open in the browser window behind it. Full screen: every key works with the sidebar hidden; ⌘1–9 and ⌘⌥↑ / ⌘⌥↓ switch tabs without bringing it out. Not built: ⌘F in a Mini Window (it has no find bar, Mini Window lane), the blank window (TB-17), extension keys in private windows (EX-06, EX-10)"}, "SB-10": {"built": "partial", "note": "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"}, "PK-01": {"built": "yes", "note": "floating card over the source tab; one Peek per window; links inside navigate it"}, "PK-02": {"built": "yes", "note": "other-site (eTLD+1) link clicks, Enter on a focused link, and target=_blank in pinned tabs/Favorites; same-site navigates. Only a navigation to the clicked link counts, so form posts, script navigations and auto-posted sign-ins stay in the tab"}, @@ -279,6 +293,7 @@ "ST-20": {"built": "partial", "note": "per-profile rows carry the profile's name, the download location (DL-07) too; 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, and on macOS ⌃ with a lone letter, which moves the caret in text fields, D60); + 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"}, @@ -307,7 +322,8 @@ "IM-09": {"built": "partial", "note": "A Chromium-family profile's extensions are listed on the types step, from its Preferences and Secure Preferences (read from copies; a name kept only in the manifest is read from it, in its default language): pinned ones first, the browser's own left out. The row is off and says they aren't importable yet; unpacked ones never come over. Not built: installing them fresh from the store with one summary confirmation, pinning the pinned ones, and the ad-blocker de-duplication (blockers are already marked), because the extensions lane's install path isn't on main yet. Firefox add-ons aren't listed (the spec covers Chromium-family sources)"}, "ST-11": {"built": "partial", "note": "Theme mode: Light / Dark / Match system (AP-01). Not built: window material (AP-10, Windows), app icon (AP-09), page defaults (P2)"}, "ST-17": {"built": "yes", "note": "Settings → About: logo, version (a click copies it), build date, Chromium and Electron versions, system, Check for updates, release notes, Copy build info. Licence and Open-source credits open credits.html, built with the app (D43): Orbit's and Electron's licences, a link to the LICENSES.chromium.html Electron ships, and the licence of each npm package whose code ships. Release notes and help need access to the private repository for now"}, - "OB-13": {"built": "partial", "note": "command-bar Help, Report a Bug, What's New and Copy Build Info; macOS Help menu (Orbit Help, Report a Bug…, Release Notes, Troubleshooting → Copy Build Info). Help opens the project README; Report a Bug opens a new issue on the public tracker with the build info filled in. The shortcut sheet entry comes with OB-12 (keys lane)"}, + "OB-12": {"built": "yes", "note": "Help → Keyboard Shortcuts (macOS Help menu; the command bar’s Keyboard Shortcuts everywhere; a key can be assigned) opens a sheet over the window with the essential keys as they are now (KS-18), grouped Everyday, Navigation, Spaces and Split View, “None” for an action with no key; Esc, ✕, a click outside, Close Tab’s keys or asking again close it; the link at the bottom opens Settings → Shortcuts. Asked from Settings or a Mini Window, it opens over the browser window behind (a new one if there is none)"}, + "OB-13": {"built": "yes", "note": "command-bar Help, Report a Bug, What's New and Copy Build Info; macOS Help menu (Orbit Help, Report a Bug…, Release Notes, Troubleshooting → Copy Build Info). Help opens the project README; Report a Bug opens a new issue on the public tracker with the build info filled in. Help → Keyboard Shortcuts (macOS) and the command bar’s Keyboard Shortcuts open the shortcut sheet (OB-12, keys lane)"}, "UP-01": {"built": "partial", "note": "checks a release feed at launch (after 30 s) and about every 4 h ± 30 min, after wake if overdue; silent, failures retried at the next check; a private in-memory session that sends only Orbit's version, system and processor type (no cookies). Nothing is checked until a feed is set in Settings → General (empty by default; test and dev builds accept only a feed on this computer). Not built: background download (builds are unsigned, so UP-10's stub)"}, "UP-02": {"built": "partial", "note": "sidebar banner above the Space switcher, in the Space's colours: a pill that opens on hover with a gradient button; ✕ hides it for 24 h or until the next launch. UP-10 form: \"New version available\" opens the release page in a tab; no relaunch until signed builds"}, "UP-03": {"built": "partial", "note": "command bar and macOS app menu \"Check for Updates…\" open Settings → About (not a separate window) and check: Checking…, up to date, available, or the failure; Download opens the release page (UP-10); a click on the version copies it. Not built: download progress and Relaunch, the Turn on offer (UP-04), a ☰ entry on Windows/Linux"}, diff --git a/src/main/archive/index.ts b/src/main/archive/index.ts index 58b9e744..47ff70f4 100644 --- a/src/main/archive/index.ts +++ b/src/main/archive/index.ts @@ -387,8 +387,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) @@ -397,6 +400,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 0d165f8b..2f5a47c5 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' @@ -60,6 +61,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), and a Peek's or a mini window's. Not an import's @@ -153,7 +156,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 71edf733..dcb8d140 100644 --- a/src/main/features.ts +++ b/src/main/features.ts @@ -8,6 +8,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' @@ -21,6 +22,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..83ee7cdf --- /dev/null +++ b/src/main/keys/bindings.ts @@ -0,0 +1,193 @@ +import { + ACTIONS, + ACTIONS_BY_ID, + type ActionDef, + buildKeymap, + defaultPriorityFor, + defaultShortcutsFor, + isFixed, +} from '../../shared/actions' +import type { BindRequest, BindResult, ShortcutPriority } from '../../shared/commands/keys' +import { + bindingProblem, + type CustomBindings, + canonicalAccelerator, + formatAccelerator, + MAX_BINDINGS, +} 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. + */ + +export { MAX_BINDINGS } + +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 (isFixed(action, platform)) for (const key of canon(defaultShortcutsFor(action, platform))) taken.add(key) + for (const action of ACTIONS) { + const raw = value[action.id] + if (isFixed(action, platform) || !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 || isFixed(action, platform) || (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 (isFixed(action, platform)) 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 (isFixed(owner, platform)) { + 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 || isFixed(action, platform) || !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 || isFixed(action, platform)) 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 || isFixed(action, platform) || (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/browser-window.ts b/src/main/keys/browser-window.ts new file mode 100644 index 00000000..05c421bc --- /dev/null +++ b/src/main/keys/browser-window.ts @@ -0,0 +1,14 @@ +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' + +/** + * The browser window an action asked from `from` acts in (OB-12, KS-12): that window, or for + * Settings or a Mini Window the browser window they belong to or the last one used; with none + * open, a new one. + */ +export function browserWindowFor(ctx: MainContext, from: WindowId | undefined): WindowId { + const { store, windows } = ctx + const isBrowser = (id: WindowId | undefined): id is WindowId => + !!id && !!windows.get(id) && !!store.state.windows[id] && store.state.windows[id]?.kind !== 'mini' + return isBrowser(from) ? from : (windows.ids().filter(isBrowser).at(-1) ?? windows.open()) +} diff --git a/src/main/keys/escape.ts b/src/main/keys/escape.ts new file mode 100644 index 00000000..0596f6bc --- /dev/null +++ b/src/main/keys/escape.ts @@ -0,0 +1,81 @@ +import { type IpcMainEvent, ipcMain, type WebContents } from 'electron' +import { PAGE_CHANNEL } from '../../shared/site' + +/** + * Esc on a loading page (KS-19, NV-04, D59). As in Chromium, the page gets Esc first, and Orbit + * stops the load only if the page didn't use it. Electron runs no isolated-world script while a + * main frame loads, so the page preload (src/preload/page.ts) says what the page did: one bit per + * trusted Esc in the main frame, sent once every handler of the page has run. + * + * Main reads the bit only while it waits after an Esc pressed in a loading tab, at most + * ESCAPE_WAIT_MS; no answer counts as not used (D60). A bit counts only from the main frame of a + * tab's page (D40), and only for an Esc pressed there: one sent with no press waiting does nothing. + */ + +/** How long main waits for the page's answer: KS-02's verdict wait (D59, D60). */ +export const ESCAPE_WAIT_MS = 250 + +/** One Esc pressed while a tab loaded, as main saw it: whether the page used it, and whether the load stopped. */ +export interface EscapeRecord { + url: string + used: boolean + stopped: boolean +} + +/** The presses still waiting for the page's answer, oldest first, by tab contents. */ +const waiting = new WeakMap void)[]>() + +/** The last few presses, for the e2e harness. */ +const log: EscapeRecord[] = [] +const LOG_SIZE = 10 + +let listening = false + +/** + * Takes the pages' answers. `isTab` says whether a web contents is a tab's (Peek and Mini Window + * pages are tabs too). Called once. + */ +export function listenForEscapes(isTab: (contents: WebContents) => boolean): void { + if (listening) return + listening = true + ipcMain.on(PAGE_CHANNEL.escape, (event: IpcMainEvent, used: unknown) => { + if (typeof used !== 'boolean') return + const contents = event.sender + const frame = event.senderFrame + try { + if (!frame || contents.isDestroyed() || !isTab(contents)) return + if (frame.frameTreeNodeId !== contents.mainFrame.frameTreeNodeId) return + } catch { + // A frame or page that went away while the message was on its way. + return + } + waiting.get(contents)?.shift()?.(used) + }) +} + +/** + * Esc was pressed in `contents` while its tab loads and no overlay is open: waits for the page's + * answer, then calls `stop` unless the page used the key or the load ended meanwhile. + */ +export function escapeThenStop(contents: WebContents, stillLoading: () => boolean, stop: () => void): void { + const url = contents.getURL() + const queue = waiting.get(contents) ?? [] + waiting.set(contents, queue) + let timer: NodeJS.Timeout | undefined + const settle = (used: boolean) => { + clearTimeout(timer) + const at = queue.indexOf(settle) + if (at >= 0) queue.splice(at, 1) + const stopped = !used && !contents.isDestroyed() && stillLoading() + if (stopped) stop() + log.push({ url, used, stopped }) + if (log.length > LOG_SIZE) log.shift() + } + queue.push(settle) + timer = setTimeout(() => settle(false), ESCAPE_WAIT_MS) +} + +/** The last presses Orbit waited on, oldest first (e2e). */ +export function escapeLog(): EscapeRecord[] { + return log.map((r) => ({ ...r })) +} 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/keys/index.ts b/src/main/keys/index.ts new file mode 100644 index 00000000..fda3ca89 --- /dev/null +++ b/src/main/keys/index.ts @@ -0,0 +1,117 @@ +import { webContents } from 'electron' +import { ACTIONS } from '../../shared/actions' +import type { BindResult, ShortcutPriority } from '../../shared/commands/keys' +import { onKeymapChange, setCustomBindings } from '../../shared/keymap' +import type { MainContext } from '../context' +import { installMenu, watchWindowsForMenu } from '../menu' +import { bind, reset, setPriority, unbind } from './bindings' +import { escapeLog, listenForEscapes } from './escape' +import { lastMenuHints } from './menu-hints' +import { registerOpenFile } from './open-file' +import { pageFocus } from './page-focus' +import { keyRouter } from './router' +import { registerShortcutSheet } from './sheet' +import { registerSpecialWindows } from './special-windows' +import { setSystemClashesForTest, systemClashes } from './system' +import { registerTabKeys } from './tab-keys' +import { registerWindowKeys } from './window-keys' + +/** + * 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), and with no window up takes ⌘O (KS-12). + onKeymapChange(() => installMenu(ctx)) + watchWindowsForMenu(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)) + }) + 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.available', () => ACTIONS.filter((a) => ctx.actions.has(a.id)).map((a) => a.id)) + commands.register('keys.systemClashes', () => systemClashes()) + registerWindowKeys(ctx) + registerTabKeys(ctx) + registerShortcutSheet(ctx) + registerOpenFile(ctx) + registerSpecialWindows(ctx) + keyRouter.init(ctx) + // Whether a page used an Esc pressed while it loads, from its preload (D59). + listenForEscapes((contents) => !!ctx.tabs.tabIdFor(contents)) + commands.register('keys.record', ({ on }, c) => keyRouter.setRecording(on ? c.windowId : undefined)) + // Tests say which keys the system keeps instead of asking it, and read the keys a menu showed, + // where Orbit knows the keyboard is in a page, and what became of Esc on loading pages. + if (ctx.e2e) { + ;(globalThis as unknown as { __orbitKeys: unknown }).__orbitKeys = { + setSystemClashes: setSystemClashesForTest, + lastMenuHints, + focusIn: (urlPart: string) => { + const contents = webContents + .getAllWebContents() + .find((w) => !w.isDestroyed() && !!ctx.tabs.tabIdFor(w) && w.getURL().includes(urlPart)) + const now = contents ? pageFocus(contents) : undefined + if (!now) return null + try { + return { frame: now.inFrame ? now.frame.url : 'main', where: now.where ?? null } + } catch { + return null + } + }, + escapes: escapeLog, + } + } +} diff --git a/src/main/keys/menu-hints.ts b/src/main/keys/menu-hints.ts new file mode 100644 index 00000000..3791bdfa --- /dev/null +++ b/src/main/keys/menu-hints.ts @@ -0,0 +1,98 @@ +import type { MenuItemConstructorOptions } from 'electron' +import { ACTIONS_BY_ID, shortcutsFor, toElectronAccelerator } from '../../shared/actions' + +/** + * Shortcut hints in Orbit's right-click menus (KS-18). An item that does what an action does shows + * the action's first key, read from the live keymap each time the menu is built, so a remap shows + * the next time the menu opens. The hint is only shown: Orbit's key router runs the keys, so the + * item never registers them. + */ + +/** Items, by label, and the action whose keys do the same. */ +export type KeyHints = readonly (readonly [label: string | RegExp, actionId: string])[] + +/** + * The tab menu and the menu of a multi-selection (src/main/tabs/menus.ts). Only items that do what + * the key does: ⌘W and ⌘⇧C act on every selected tab, as Close 3 Tabs and Copy Links do, but the + * Markdown copy key copies one link. + */ +export const TAB_MENU_KEYS: KeyHints = [ + ['Pin Tab', 'tab.togglePin'], + ['Unpin Tab', 'tab.togglePin'], + ['Add to Favorites', 'tab.favorite'], + ['Reset to Pinned URL', 'tab.resetToPinned'], + ['Rename…', 'tab.rename'], + ['Copy Link', 'tab.copyUrl'], + ['Copy Links', 'tab.copyUrl'], + ['Copy Link as Markdown', 'tab.copyUrlMarkdown'], + ['Duplicate', 'tab.duplicate'], + ['Mute Tab', 'tab.toggleMute'], + ['Unmute Tab', 'tab.toggleMute'], + ['Close Tab', 'tab.close'], + [/^Close \d+ Tabs$/, 'tab.close'], +] + +/** + * The page menu (src/main/tabs/context-menu.ts, src/main/nav/page-menu.ts). Copy Page URL copies + * the address as it is, and the Copy URL key copies it without tracking parameters, so it gets no hint. + */ +export const PAGE_MENU_KEYS: KeyHints = [ + ['Back', 'nav.back'], + ['Forward', 'nav.forward'], + ['Reload', 'nav.reload'], + ['Print…', 'page.print'], + ['View Page Source', 'page.viewSource'], +] + +/** A split pane's menu (src/main/split/index.ts): its items do to that pane what the keys do to the focused one. */ +export const SPLIT_PANE_MENU_KEYS: KeyHints = [ + ['Add Split Right', 'split.addRight'], + ['Add Split Left', 'split.addLeft'], + ['Add Split Above', 'split.addAbove'], + ['Add Split Below', 'split.addBelow'], + ['Flip Orientation', 'split.flip'], + ['Equalize Sizes', 'split.equalize'], + ['Separate Tabs', 'split.separate'], + ['Close Pane', 'split.close'], +] + +/** The hints of the menu built last, as "Label=Accelerator" (tests). */ +let lastHints: string[] = [] + +/** The action an item's label names in `hints`, if any. */ +function actionOf(label: string, hints: KeyHints): string | undefined { + return hints.find(([match]) => (typeof match === 'string' ? match === label : match.test(label)))?.[1] +} + +/** + * The menu with each item that `hints` names showing its action's first key now. An item with a + * role or an accelerator of its own keeps it; submenus are looked through too. + */ +export function withKeyHints( + items: MenuItemConstructorOptions[], + hints: KeyHints, + platform: string = process.platform, +): MenuItemConstructorOptions[] { + const shown: string[] = [] + const walk = (list: MenuItemConstructorOptions[]): MenuItemConstructorOptions[] => + list.map((item) => { + const submenu = Array.isArray(item.submenu) ? walk(item.submenu) : item.submenu + const actionId = + item.label && !item.role && item.accelerator === undefined ? actionOf(item.label, hints) : undefined + const action = actionId ? ACTIONS_BY_ID[actionId] : undefined + const key = action ? shortcutsFor(action, platform)[0] : undefined + const next = submenu === item.submenu ? item : { ...item, submenu } + if (!key) return next + const accelerator = toElectronAccelerator(key) + shown.push(`${item.label}=${accelerator}`) + return { ...next, accelerator, registerAccelerator: false } + }) + const out = walk(items) + lastHints = shown + return out +} + +/** The hints of the menu built last (tests). */ +export function lastMenuHints(): string[] { + return lastHints +} diff --git a/src/main/keys/open-file.ts b/src/main/keys/open-file.ts new file mode 100644 index 00000000..6c685d10 --- /dev/null +++ b/src/main/keys/open-file.ts @@ -0,0 +1,44 @@ +import { pathToFileURL } from 'node:url' +import { dialog, type OpenDialogOptions } from 'electron' +import { ACTIONS_BY_ID, type ActionDef, BORROWED_KEYS } from '../../shared/actions' +import type { OrbitState, WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { browserWindowFor } from './browser-window' + +/** + * Open File… (KS-12): files from the disk open in new tabs of the window it was asked from, the + * last one chosen selected. ⌘O / Ctrl+O first serve an open Peek (PK-07) and a Mini Window + * (MW-05), whose action owns those keys; anywhere else the same keys open a file. + */ + +const OPEN_FILE = 'file.open' + +/** + * The action a key press runs in the window `windowOf` names (asked only for Open Peek as Tab's + * keys): they open a file where there is no Peek or Mini Window. + */ +export function actionInWindow(action: ActionDef, state: OrbitState, windowOf: () => WindowId | undefined): ActionDef { + if (BORROWED_KEYS[OPEN_FILE]?.from !== action.id) return action + const windowId = windowOf() + const win = windowId ? state.windows[windowId] : undefined + if (win?.kind === 'mini' || win?.overlay.kind === 'peek') return action + return ACTIONS_BY_ID[OPEN_FILE] ?? action +} + +export function registerOpenFile(ctx: MainContext): void { + const { windows, commands, actions } = ctx + actions.register(OPEN_FILE, async (c) => { + const windowId = browserWindowFor(ctx, c.windowId ?? windows.focusedWindowId()) + const win = windows.get(windowId) + // Asked from Settings, a Mini Window or a page's pop-up: the window the files open in comes forward. + if (win && !win.isDestroyed() && !win.isFocused()) win.focus() + const options: OpenDialogOptions = { title: 'Open File', properties: ['openFile', 'multiSelections'] } + const { canceled, filePaths } = + win && !win.isDestroyed() ? await dialog.showOpenDialog(win, options) : await dialog.showOpenDialog(options) + if (canceled) return + for (const [i, path] of filePaths.entries()) { + const url = pathToFileURL(path).href + await commands.run('tabs.create', { url, activate: i === filePaths.length - 1 }, { windowId }) + } + }) +} diff --git a/src/main/keys/page-focus.ts b/src/main/keys/page-focus.ts new file mode 100644 index 00000000..1b33f46a --- /dev/null +++ b/src/main/keys/page-focus.ts @@ -0,0 +1,309 @@ +import type { WebContents, WebFrameMain } from 'electron' +import { ANSWER_WAIT_MS, EDITABLE, type PageVerdict, pageVerdict, signature } from './page-keys' + +/** + * Where the keyboard is in a tab's page, known in main before a key arrives (KS-16, D60), so Back + * and Forward can run at once outside text fields without asking a page that may be busy, and a + * press with focus in a frame is judged by that frame. + * + * Each frame watched says, in its own world, where the keyboard is in it, and says it again when + * that changes: a script that answers when focus moves (`FOCUS`), sent again after each answer. + * The page's main frame is watched for as long as its document lives. The frame inside it that has + * the keyboard (`contents.focusedFrame`) is followed too, and also notes the presses Orbit may ask + * about, since the main frame's watcher (page-keys.ts) never sees keys pressed in a frame. + * + * What a frame says about itself is that page's word: a page that says the wrong thing only + * changes what its own keys do. Answers are checked before they are used. + */ + +/** + * Where the keyboard is in one frame: + * - 'field': on one of its text fields; + * - 'page': elsewhere in it; + * - 'frame': in a frame inside it; + * - 'away': not in it. + */ +export type FrameWhere = 'field' | 'page' | 'frame' | 'away' + +const WHERE: ReadonlySet = new Set(['field', 'page', 'frame', 'away']) + +/** A key press a followed frame noted: as the main frame's watcher notes them (page-keys.ts). */ +interface KeyRecord { + sig: string + at: number + inField: boolean + prevented: boolean +} + +/** + * The script a watched frame runs: answers `{ where }` once where the keyboard is differs from + * `known` or focus moves, and, with `keys`, `{ where, key }` once a key Orbit may ask about has + * been through every handler of the frame (keys with ⌘, Ctrl or Alt, and F1–F24). Leaves nothing + * behind in the page. + */ +const FOCUS = (known: FrameWhere | null, keys: boolean) => `new Promise((resolve) => { + const editable = ${EDITABLE} + const where = () => { + if (!document.hasFocus()) return 'away' + const el = document.activeElement + if (el && /^(IFRAME|FRAME|OBJECT|EMBED|FENCEDFRAME)$/.test(el.tagName)) return 'frame' + return editable(el) ? 'field' : 'page' + } + const FOCUS_EVENTS = ['focusin', 'focusout', 'focus', 'blur'] + let done = false + const finish = (answer) => { + if (done) return + done = true + for (const type of FOCUS_EVENTS) removeEventListener(type, moved, true) + removeEventListener('keydown', pressed, true) + resolve(answer) + } + const moved = () => setTimeout(() => finish({ where: where() }), 0) + const pressed = (e) => { + if (!e.isTrusted || ['Control', 'Meta', 'Alt', 'Shift', 'AltGraph'].includes(e.key)) return + if (!e.metaKey && !e.ctrlKey && !e.altKey && !/^F\\d{1,2}$/.test(e.key)) return + const key = { + sig: [e.metaKey, e.ctrlKey, e.altKey, e.shiftKey, e.code].join(','), + at: Date.now(), + inField: editable(document.activeElement), + } + // After the whole dispatch: the frame's handlers have all had the event. + setTimeout(() => finish({ where: where(), key: { ...key, prevented: e.defaultPrevented } }), 0) + } + for (const type of FOCUS_EVENTS) addEventListener(type, moved, true) + if (${keys}) addEventListener('keydown', pressed, true) + if (where() !== ${JSON.stringify(known)}) finish({ where: where() }) +})` + +/** A frame's answer, if it is one. */ +function readAnswer(value: unknown): { where: FrameWhere; key?: KeyRecord } | undefined { + if (!value || typeof value !== 'object') return undefined + const { where, key } = value as { where?: unknown; key?: unknown } + if (typeof where !== 'string' || !WHERE.has(where)) return undefined + if (key === undefined) return { where: where as FrameWhere } + if (!key || typeof key !== 'object') return undefined + const { sig, at, inField, prevented } = key as Record + if (typeof sig !== 'string' || sig.length > 64 || typeof at !== 'number' || !Number.isFinite(at)) return undefined + if (typeof inField !== 'boolean' || typeof prevented !== 'boolean') return undefined + return { where: where as FrameWhere, key: { sig, at, inField, prevented } } +} + +/** Reads a frame's property; undefined once the frame is gone. */ +function read(get: () => T): T | undefined { + try { + return get() + } catch { + return undefined + } +} + +const sameFrame = (a: WebFrameMain, b: WebFrameMain): boolean => + a === b || + read(() => a.frameTreeNodeId === b.frameTreeNodeId && a.processId === b.processId && a.routingId === b.routingId) === + true + +/** One watched frame. */ +interface Watch { + frame: WebFrameMain + /** What it said last; undefined until it answers. */ + where: FrameWhere | undefined + /** Whether it notes key presses (a followed frame inside the page). */ + keys: boolean + /** Its last key presses, oldest first. */ + records: KeyRecord[] + /** Verdicts waiting for a press to show up in `records`. */ + wake: Set<() => void> + /** When it answered lately: a frame that keeps answering is asked less often. */ + answers: number[] +} + +/** Where the keyboard is in a page now, and how to learn what the page did with a press there. */ +export interface FocusNow { + /** The frame with the keyboard. */ + frame: WebFrameMain + /** Whether that is a frame inside the page rather than the page itself. */ + inFrame: boolean + /** Where in it, as far as Orbit knows. */ + where: FrameWhere | undefined + /** What that frame did with the press `input`, pressed at `pressedAt` (main's clock). */ + verdict(input: Electron.Input, pressedAt: number): Promise +} + +/** Answers this many times a second, at most, before a frame is asked again only after a pause. */ +const ANSWERS_PER_SECOND = 30 +const PAUSE_MS = 200 + +class PageFocus { + private main: Watch | undefined + private child: Watch | undefined + private retry: NodeJS.Timeout | undefined + + constructor(private readonly contents: WebContents) {} + + /** Watches the main frame's document: each time one commits. */ + start(): void { + const frame = this.frameOf(() => this.contents.mainFrame) + this.child = undefined + this.main = frame ? this.watch(frame, false) : undefined + } + + stop(): void { + this.main = undefined + this.child = undefined + clearTimeout(this.retry) + } + + /** A frame inside the page loaded a new document: the followed one is watched again in it. */ + frameNavigated(processId: number, routingId: number): void { + const child = this.child + if (!child || read(() => child.frame.processId === processId && child.frame.routingId === routingId) !== true) + return + this.child = this.watch(child.frame, true) + } + + /** Follows the frame inside the page that has the keyboard, if one does. */ + sync(): void { + const main = this.main + const focused = this.frameOf(() => this.contents.focusedFrame) + if (!main || !focused || sameFrame(focused, main.frame)) { + this.child = undefined + return + } + if (this.child && sameFrame(this.child.frame, focused)) return + this.child = this.watch(focused, true) + } + + now(): FocusNow | undefined { + this.sync() + const child = this.child + if (child) { + return { + frame: child.frame, + inFrame: true, + where: child.where, + verdict: (input, pressedAt) => this.frameVerdict(child, input, pressedAt), + } + } + const main = this.main + if (!main) return undefined + return { + frame: main.frame, + inFrame: false, + where: main.where, + verdict: (input, pressedAt) => pageVerdict(this.contents, input, pressedAt), + } + } + + private frameOf(get: () => WebFrameMain | null): WebFrameMain | undefined { + if (this.contents.isDestroyed()) return undefined + return read(get) ?? undefined + } + + private watch(frame: WebFrameMain, keys: boolean): Watch { + const watch: Watch = { frame, where: undefined, keys, records: [], wake: new Set(), answers: [] } + this.ask(watch) + return watch + } + + private current(watch: Watch): boolean { + return watch === this.main || watch === this.child + } + + private ask(watch: Watch): void { + let asked: Promise + try { + asked = watch.frame.executeJavaScript(FOCUS(watch.where ?? null, watch.keys)) + } catch { + return + } + asked.then( + (value) => { + if (!this.current(watch)) return + const answer = readAnswer(value) + // A frame that answers something else isn't asked again; its next document is. + if (!answer) return + watch.where = answer.where + if (answer.key) { + watch.records.push(answer.key) + if (watch.records.length > 16) watch.records.shift() + for (const wake of [...watch.wake]) wake() + } + const now = Date.now() + watch.answers = watch.answers.filter((t) => now - t < 1000) + watch.answers.push(now) + if (watch.answers.length > ANSWERS_PER_SECOND) { + setTimeout(() => { + if (this.current(watch)) this.ask(watch) + }, PAUSE_MS) + } else { + this.ask(watch) + } + if (watch === this.main) this.follow(answer.where) + }, + // The frame's document went away: the next one is watched when it commits. + () => {}, + ) + } + + /** + * The main frame says the keyboard went into a frame inside it: follows that frame. Chromium may + * say which frame has focus a moment after the page does, so this looks again a few times. + */ + private follow(where: FrameWhere, tries = 5): void { + clearTimeout(this.retry) + this.sync() + if (where !== 'frame' || this.child || tries <= 0) return + this.retry = setTimeout(() => { + if (this.main?.where === 'frame') this.follow('frame', tries - 1) + }, 20) + } + + /** What a followed frame did with a press: from what it noted, waiting for it at most ANSWER_WAIT_MS. */ + private frameVerdict(watch: Watch, input: Electron.Input, pressedAt: number): Promise { + const sig = signature(input) + const since = pressedAt - 10 + const find = () => watch.records.find((r) => r.sig === sig && r.at >= since) + const verdict = (r: KeyRecord): PageVerdict => ({ seen: true, prevented: r.prevented, inField: r.inField }) + const found = find() + if (found) return Promise.resolve(verdict(found)) + return new Promise((resolve) => { + const done = (answer: PageVerdict) => { + clearTimeout(timer) + watch.wake.delete(wake) + resolve(answer) + } + const wake = () => { + const r = find() + if (r) done(verdict(r)) + } + // No answer: the frame left the key alone (D60). + const timer = setTimeout(() => done(undefined), ANSWER_WAIT_MS) + watch.wake.add(wake) + }) + } +} + +const watched = new WeakMap() + +/** Watches where the keyboard is in a tab's page, from its first document on (KS-16, D60). */ +export function watchPageFocus(contents: WebContents): void { + if (watched.has(contents)) return + const focus = new PageFocus(contents) + watched.set(contents, focus) + contents.on('did-navigate', () => focus.start()) + contents.on('did-frame-navigate', (_event, _url, _code, _status, isMainFrame, processId, routingId) => { + if (!isMainFrame) focus.frameNavigated(processId, routingId) + }) + // A key typed into a frame the page moved the keyboard to, which the page doesn't say. + contents.on('before-input-event', (_event, input) => { + if (input.type === 'keyDown') focus.sync() + }) + contents.once('destroyed', () => focus.stop()) + focus.start() +} + +/** Where the keyboard is in a tab's page now; undefined for a page Orbit doesn't watch. */ +export function pageFocus(contents: WebContents): FocusNow | undefined { + if (contents.isDestroyed()) return undefined + return watched.get(contents)?.now() +} diff --git a/src/main/keys/page-keys.ts b/src/main/keys/page-keys.ts new file mode 100644 index 00000000..4f881282 --- /dev/null +++ b/src/main/keys/page-keys.ts @@ -0,0 +1,139 @@ +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 + +/** + * Whether an element is a text field: one that types or edits text (page script source, shared by + * the watcher here and the focus mirror in page-focus.ts). + */ +export const EDITABLE = `(el) => { + 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) +}` + +/** A key press as the page scripts note it: its modifiers and physical key. */ +export function signature(input: Pick): string { + return [input.meta, input.control, input.alt, input.shift, input.code].join(',') +} + +/** + * 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 = ${EDITABLE} + 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(document.activeElement) + // 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 frame with the keyboard: whether it called preventDefault, and whether focus was in a text field. */ + | { seen: true; prevented: boolean; inField: boolean } + /** The main frame's watcher didn't see the press within the wait: a handler of the page that ran first stopped it. */ + | { seen: false } + /** No answer: the page has no watcher (loading, or a page Orbit can't script), or answered too late. */ + | undefined + +/** How long the page's watcher waits for the press to reach it (a little less than ANSWER_WAIT_MS). */ +export const VERDICT_WAIT_MS = 200 + +/** + * How long main waits for the page's answer at all; a page whose renderer is busy or hung answers + * late, and no answer counts as the page leaving the key alone (D60). + */ +export const ANSWER_WAIT_MS = 250 + +/** + * 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. + */ +export async function pageVerdict( + contents: WebContents, + input: Electron.Input, + pressedAt: number, +): Promise { + if (!pageCanAnswer(contents)) return undefined + const sig = signature(input) + 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 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 new file mode 100644 index 00000000..a279a2d1 --- /dev/null +++ b/src/main/keys/router.ts @@ -0,0 +1,251 @@ +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 { actionInWindow } from './open-file' +import { pageFocus, watchPageFocus } from './page-focus' +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 + +/** 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 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) + watchPageFocus(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. */ + 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 + } + + /** + * Keys of the recent-tab switcher while it is up (⌃Tab held, KS-06, TB-05), with Control still + * held: + * - W closes the highlighted tab; a held W closes one. + * - ` steps back, on every platform (D60). The rest of the time ⌃` is the page's. + * 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 + const close = input.code === 'KeyW' || input.key.toLowerCase() === 'w' + const back = !input.shift && (input.code === 'Backquote' || input.key === '`') + if (!close && !back) return false + const windowId = windowOf() + const recent = this.ctx?.tabs.recentSwitch + if (!windowId || !recent?.active(windowId)) return false + if (back) void this.ctx?.actions.run('tab.recentBack', { windowId }) + else if (!input.isAutoRepeat) recent.closeHighlighted(windowId) + return true + } + + /** + * The action a key press runs where it was pressed: Open Peek as Tab's keys open a file in a + * window with no Peek or Mini Window (KS-12). + */ + actionIn(action: ActionDef | undefined, windowOf: () => WindowId | undefined): ActionDef | undefined { + if (!action || !this.ctx) return action + return actionInWindow(action, this.ctx.store.state, windowOf) + } + + /** + * 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 ⌘[ ⌘]): + * outside a text field it runs at once and the page never sees the key, however busy the + * page is (D60). In a text field the page gets the key first, and the action runs unless the + * page used it. + * With the keyboard in a frame inside the page, that frame's fields and handlers decide (D60). + * No answer from the page within ANSWER_WAIT_MS counts as the page leaving the key alone. 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, + 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 + // 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 + // Where the keyboard is, known before the press: the page itself or a frame inside it. + const focus = pageFocus(contents) + const verdict = (pressedAt: number) => + focus ? focus.verdict(input, pressedAt) : pageVerdict(contents, input, pressedAt) + 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') { + // Outside a text field the action runs at once (D60); so it does in a frame not known to + // have a text field focused. + if (focus && (focus.where === 'page' || (focus.inFrame && focus.where !== 'field'))) return false + this.firstPresses.set(key, first) + void verdict(Date.now()).then((answer) => { + if (this.firstPresses.get(key) === first) this.firstPresses.delete(key) + if (first.menuRan || contents.isDestroyed()) return + // Only a text field whose page used the key keeps it (KS-16). + if (answer?.seen && answer.prevented && answer.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 verdict(Date.now()).then((answer) => { + // 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 + } + // No answer, or the page saw the press and left it alone. + if (!answer || (answer.seen && !answer.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. */ +export const keyRouter = new KeyRouter() diff --git a/src/main/keys/sheet.ts b/src/main/keys/sheet.ts new file mode 100644 index 00000000..4597eae6 --- /dev/null +++ b/src/main/keys/sheet.ts @@ -0,0 +1,39 @@ +import { SHORTCUT_SHEET } from '../../shared/commands/keys' +import type { WindowId } from '../../shared/state' +import type { MainContext } from '../context' +import { browserWindowFor } from './browser-window' + +/** + * Help → Keyboard Shortcuts (OB-12): the sheet of essential shortcuts shows in a browser window's + * overlay (src/renderer/src/keys/ShortcutSheet.tsx). From Settings or a Mini Window it shows in + * the browser window they belong to, or the last one used; with none open, in a new one. + */ +export function registerShortcutSheet(ctx: MainContext): void { + const { store, windows, commands, actions } = ctx + const sheetOpen = (windowId: WindowId | undefined): boolean => { + const overlay = windowId ? store.state.windows[windowId]?.overlay : undefined + return overlay?.kind === 'panel' && overlay.panel === SHORTCUT_SHEET + } + const close = (windowId: WindowId) => void commands.run('overlay.close', {}, { windowId }) + + actions.register('help.shortcuts', (c) => { + const windowId = browserWindowFor(ctx, c.windowId ?? windows.focusedWindowId()) + // Asked again while it is up: it closes, as the command bar does. + if (sheetOpen(windowId)) { + close(windowId) + return + } + const win = windows.get(windowId) + if (win && !win.isDestroyed() && !win.isFocused()) win.focus() + void commands.run('overlay.open', { overlay: { kind: 'panel', panel: SHORTCUT_SHEET } }, { windowId }) + }) + + // KS-19: while the sheet is up, Close Tab's keys close the sheet rather than the tab under it. + actions.intercept((id, c) => { + if (id !== 'tab.close') return false + const windowId = c.windowId ?? windows.focusedWindowId() + if (!windowId || !sheetOpen(windowId)) return false + close(windowId) + return true + }) +} diff --git a/src/main/keys/special-windows.ts b/src/main/keys/special-windows.ts new file mode 100644 index 00000000..884ea052 --- /dev/null +++ b/src/main/keys/special-windows.ts @@ -0,0 +1,43 @@ +import { isPrivateWindow } from '../../shared/private' +import type { MainContext } from '../context' + +/** + * Keys in special windows (KS-20). A private window has no Spaces, pinned tabs, Favorites or + * folders (NV-11, SB-25), so the actions that need them do nothing there, whether a key, a menu or + * the command bar asks, and a short hint says why rather than leaving the press unanswered. Mini + * Windows route their keys themselves (src/main/miniwindow/index.ts). + */ + +/** What the hint says. */ +export const PRIVATE_WINDOW_HINT = 'Not available in a private window' + +/** + * Actions on pinned tabs, Favorites, folders, Today's Clear and the Archive (⌘D, ⌘⇧K…); every Space + * action is one too. A private window keeps nothing in the Archive (SB-25). + */ +const NEEDS_SPACES = new Set([ + 'tab.togglePin', + 'tab.favorite', + 'tab.resetToPinned', + 'tab.pickSpace', + 'pinned.toggleCollapsed', + 'folder.new', + 'tabs.clearToday', + 'app.archive', +]) + +/** Whether an action needs the Spaces, pinned tabs, Favorites or folders a private window doesn't have. */ +export function needsSpaces(actionId: string): boolean { + return NEEDS_SPACES.has(actionId) || actionId.startsWith('space.') +} + +export function registerSpecialWindows(ctx: MainContext): void { + const { store, windows, actions } = ctx + actions.intercept((id, c) => { + if (!needsSpaces(id)) return false + const windowId = c.windowId ?? windows.focusedWindowId() + if (!windowId || !isPrivateWindow(store.state, windowId)) return false + ctx.toast(windowId, PRIVATE_WINDOW_HINT) + return true + }) +} 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/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 84ce7789..9783464f 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -1,8 +1,10 @@ -import { app, Menu, type MenuItemConstructorOptions } from 'electron' -import { ACTIONS_BY_ID, shortcutsFor, toElectronAccelerator } from '../shared/actions' +import { app, BrowserWindow, Menu, type MenuItemConstructorOptions } from 'electron' +import { ACTIONS_BY_ID, borrowedShortcutsFor, 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' +import { keyRouter } from './keys/router' /** * The macOS menu bar, built from the shared action list so menu labels and @@ -16,16 +18,26 @@ export function installMenu(ctx: MainContext): void { Menu.setApplicationMenu(null) return } + const windowShown = anyWindowShown() const item = (id: string, label?: string): MenuItemConstructorOptions => { const action = ACTIONS_BY_ID[id] - const accel = action ? shortcutsFor(action, 'darwin')[0] : undefined + const own = action ? shortcutsFor(action, 'darwin')[0] : undefined + // Keys lent by another action (⌘O for Open File where there is no Peek, KS-12) are shown too. + const lent = own || !action ? undefined : borrowedShortcutsFor(action, 'darwin')?.keys[0] + const accel = own ?? lent 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. So are lent keys while a window is shown: + // the router picks the action. With none, the menu is all there is, and ⌘O opens a file (KS-12). + ...(accel && ((lent && windowShown) || 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' && ctx.windows.closeFocusedForeign()) return + if (!windowId && (id === 'tab.close' || id === 'window.close') && ctx.windows.closeFocusedForeign()) return void ctx.actions.run(id, { windowId }) }, } @@ -59,9 +71,10 @@ export function installMenu(ctx: MainContext): void { item('window.new'), item('window.newMini'), item('window.newPrivate'), + item('file.open'), { 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) }, @@ -168,6 +181,8 @@ export function installMenu(ctx: MainContext): void { // Help and feedback (OB-13), release notes (UP-06), Restore Data… (AR-11) and build info (UP-07). submenu: [ item('help.open', 'Orbit Help'), + // The essential shortcuts (OB-12). + item('help.shortcuts'), item('help.reportBug', 'Report a Bug…'), item('help.releaseNotes', 'Release Notes'), { type: 'separator' }, @@ -178,3 +193,41 @@ export function installMenu(ctx: MainContext): void { ] Menu.setApplicationMenu(Menu.buildFromTemplate(template)) } + +/** + * Whether an Orbit window is on screen. Key presses in one go through the key router + * (src/main/windows.ts `attachShortcuts`); with none, only the menu bar sees them (KS-12). + */ +function anyWindowShown(): boolean { + return BrowserWindow.getAllWindows().some((w) => !w.isDestroyed() && w.isVisible() && !w.isMinimized()) +} + +let watching = false + +/** + * macOS: builds the menu bar again when the last Orbit window leaves the screen, and when one comes + * back, so that the keys the menu only shows while a window is up (⌘O for Open File…) work from the + * menu with none (KS-12). + */ +export function watchWindowsForMenu(ctx: MainContext): void { + if (process.platform !== 'darwin' || watching) return + watching = true + let shown = anyWindowShown() + const check = () => { + // Once Electron has the window's new state. + setImmediate(() => { + const now = anyWindowShown() + if (now === shown) return + shown = now + installMenu(ctx) + }) + } + app.on('browser-window-created', (_event, win) => { + win.on('show', check) + win.on('hide', check) + win.on('minimize', check) + win.on('restore', check) + win.on('closed', check) + check() + }) +} diff --git a/src/main/miniwindow/index.ts b/src/main/miniwindow/index.ts index 1a131233..15f70c75 100644 --- a/src/main/miniwindow/index.ts +++ b/src/main/miniwindow/index.ts @@ -665,6 +665,12 @@ export function registerMiniWindow(ctx: MainContext): void { if (items.length === 0) return Menu.buildFromTemplate(items).popup({ window: mini.win, x: Math.round(x), y: Math.round(y) }) }) + // Esc in the address field: the keyboard goes back to the page, which had it before ⌘L (KS-16, KS-20). + commands.register('mini.focusPage', (_args, c) => { + const mini = miniOf(c) + const contents = mini?.tabId ? tabs.contents(mini.tabId) : undefined + if (contents && !contents.isDestroyed()) contents.focus() + }) commands.register('mini.control', ({ op }, c) => { const mini = miniOf(c) if (!mini || mini.win.isDestroyed()) return @@ -690,12 +696,25 @@ export function registerMiniWindow(ctx: MainContext): void { actions.register('mini.toggleAll', toggleAll) // In a Mini Window, keys act on its page; sidebar, Space and tab-list keys do nothing (KS-20). - const APP_ACTIONS = new Set(['window.new', 'window.newMini', 'window.newPrivate', 'app.settings', 'app.archive']) + // Help → Keyboard Shortcuts, File → Open File… and the Library's keys (⌘Y / Ctrl+H for History, + // ⌘⇧L) act in the browser window behind (OB-12, KS-12, AR-10, keys lane). + const APP_ACTIONS = new Set([ + 'window.new', + 'window.newMini', + 'window.newPrivate', + 'app.settings', + 'app.archive', + 'app.history', + 'app.library', + 'help.shortcuts', + 'file.open', + ]) actions.intercept((id, c) => { const mini = miniOf(c) if (!mini) return false switch (id) { case 'tab.close': + case 'window.close': closeWindow(mini) return true case 'peek.expand': @@ -705,6 +724,8 @@ export function registerMiniWindow(ctx: MainContext): void { if (mini.tabId) send(mini, 'mini:choose-space') return true case 'url.edit': + // The keyboard goes to the address field too: the page had it (KS-20, keys lane). + if (!mini.win.isDestroyed()) mini.win.webContents.focus() send(mini, 'mini:edit-url') return true case 'tab.new': { diff --git a/src/main/nav/index.ts b/src/main/nav/index.ts index e46c88ba..dd302ddf 100644 --- a/src/main/nav/index.ts +++ b/src/main/nav/index.ts @@ -6,6 +6,7 @@ import { historyEntryLabel, historyMenuIndexes, shownTabId } from '../../shared/ import { isPrivateTab, isPrivateWindow } from '../../shared/private' import type { TabId, WindowId } from '../../shared/state' import type { MainContext } from '../context' +import { escapeThenStop } from '../keys/escape' import type { CallContext } from '../registry' import type { TabOps, TabPage } from '../tabs/ops' import { type LinkPillInfo, registerLinkPill } from './link-pill' @@ -344,16 +345,24 @@ export function registerNav(ctx: MainContext, ops: TabOps): void { registerPageBridge(ctx, permissions) ctx.hooks.tabCreated.push((tabId, contents) => { - // Esc stops a loading page (NV-04). The page still gets the key; an open overlay (command bar, - // find bar, Peek) holds keyboard focus and takes Esc before the page could see it (KS-19). + // Esc stops a loading page (NV-04), unless the page uses it: the page gets the key first, and + // Orbit stops the load once the page's preload says no handler took it (D59, keys lane). An + // open overlay (command bar, find bar, Peek) holds keyboard focus and takes Esc before the page + // could see it (KS-19). contents.on('before-input-event', (_event, input) => { if (input.type !== 'keyDown' || input.key !== 'Escape') return if (input.meta || input.control || input.alt || input.shift) return if (!store.state.tabs[tabId]?.loading) return const windowId = windows.windowOfTab(tabId) if (windowId && store.state.windows[windowId]?.overlay.kind !== 'none') return - pending.markStopped(tabId) - contents.stop() + escapeThenStop( + contents, + () => !!store.state.tabs[tabId]?.loading, + () => { + pending.markStopped(tabId) + contents.stop() + }, + ) }) // The mouse's back/forward buttons over a page need nothing here: the page itself goes back or // forward one entry on release (Chromium), unless it handles the buttons (NV-01). Over the 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/persist.ts b/src/main/persist.ts index b1235934..d8c4ba4c 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' @@ -167,6 +168,7 @@ export function normalizeState(input: Partial): OrbitState { } normalizeDownloads(state) normalizeSettingsPrefs(state) + normalizeKeySettings(state.settings, process.platform) return state } diff --git a/src/main/split/index.ts b/src/main/split/index.ts index 6a37e4c9..ed6d2bbc 100644 --- a/src/main/split/index.ts +++ b/src/main/split/index.ts @@ -22,6 +22,7 @@ import type { ContainerRef, OrbitState, SplitId, SplitLayout, TabId, WindowId } import { paletteFor } from '../../shared/theme' import { insertItem, locate, sectionOf, splitOfTab } from '../../shared/tree' import type { MainContext } from '../context' +import { SPLIT_PANE_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import type { CallContext } from '../registry' import { makeTab } from '../tabs/model' import { activeTabOf } from '../windows' @@ -616,7 +617,8 @@ export function registerSplit(ctx: MainContext): void { commands.register('split.paneMenu', ({ tabId }, c) => { const windowId = windowOf(c) - const items = paneControls(tabId, windowId) + // Keys lane: an item that a key also does shows the key (KS-18). + const items = withKeyHints(paneControls(tabId, windowId), SPLIT_PANE_MENU_KEYS) const win = windowId ? windows.get(windowId) : undefined if (items.length > 0) Menu.buildFromTemplate(items).popup(win ? { window: win } : {}) }) diff --git a/src/main/tabs/commands.ts b/src/main/tabs/commands.ts index c2866cf3..e1997536 100644 --- a/src/main/tabs/commands.ts +++ b/src/main/tabs/commands.ts @@ -24,6 +24,8 @@ import { } from '../../shared/tree' import { engineForWindow, parseInput } from '../../shared/url' import type { MainContext } from '../context' +import { KEEP_KEYBOARD } from '../keys/focus' +import { TAB_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import type { CallContext } from '../registry' import { tabSplitMenuItems } from '../split/menu' import { TabMaintenance } from './maintenance' @@ -372,7 +374,9 @@ export function registerTabs(ctx: MainContext): { ops: TabOps; maintenance: TabM const windowId = windowOf(c) const state = store.state const mo = menuOps(windowId) - const all = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? tabMenu(state, id, selection, mo) : [] + // Keys lane: an item that a key also does shows the key (KS-18). + const forTab = (tabId: TabId) => withKeyHints(tabMenu(state, tabId, selection, mo), TAB_MENU_KEYS) + const all = isFolderId(id) ? folderMenu(state, id, mo) : isTabId(id) ? forTab(id) : [] // Private windows have no pinned tabs, Favorites, folders or other Spaces (NV-11, SB-25). const items = isTabId(id) && isPrivateTab(state, id) ? privateTabMenu(all) : all if (items.length === 0) return @@ -465,6 +469,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 @@ -682,6 +687,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/context-menu.ts b/src/main/tabs/context-menu.ts index f521fa5a..a16aa96f 100644 --- a/src/main/tabs/context-menu.ts +++ b/src/main/tabs/context-menu.ts @@ -3,6 +3,7 @@ import { pageMayOpen } from '../../shared/link-routing' import type { TabId } from '../../shared/state' import { engineFor, searchUrl } from '../../shared/url' import type { MainContext } from '../context' +import { PAGE_MENU_KEYS, withKeyHints } from '../keys/menu-hints' import { dictionaryItems, keepPageMenu, @@ -134,6 +135,8 @@ export function showPageContextMenu(ctx: MainContext, tabId: TabId, contents: We ) } items.push({ label: 'Inspect', click: () => contents.inspectElement(params.x, params.y) }) - if (keepPageMenu(ctx, tabId, items)) return - Menu.buildFromTemplate(items).popup() + // Keys lane: shortcut hints (KS-18). + const hinted = withKeyHints(items, PAGE_MENU_KEYS) + if (keepPageMenu(ctx, tabId, hinted)) return + Menu.buildFromTemplate(hinted).popup() } diff --git a/src/main/tabs/runtime.ts b/src/main/tabs/runtime.ts index 9d3987de..d49416a1 100644 --- a/src/main/tabs/runtime.ts +++ b/src/main/tabs/runtime.ts @@ -12,9 +12,10 @@ 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' import { showPageContextMenu } from './context-menu' import { FaviconStore } from './favicon-store' import { isIco, readIco } from './ico' @@ -157,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 /** @@ -218,6 +221,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) @@ -548,6 +553,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 2cd1e57f..9c77dab1 100644 --- a/src/main/windows.ts +++ b/src/main/windows.ts @@ -17,6 +17,8 @@ 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 { keyRouter } from './keys/router' import { focusIsEditable } from './nav/focus' import { fastSwitchPanes } from './split/layout' @@ -168,6 +170,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, } } @@ -1080,12 +1084,30 @@ 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) + // 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 + } + // Where the key was pressed may decide the action (keys lane: ⌘O opens a file outside a Peek, KS-12). + const action = keyRouter.actionIn( + actionForInput(input, process.platform), + () => this.windowIdFor(contents) ?? this.focusedWindowId(), + ) if (!action || !this.ctx.actions.has(action.id)) { 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/preload/page.ts b/src/preload/page.ts index d5edec99..d833c20d 100644 --- a/src/preload/page.ts +++ b/src/preload/page.ts @@ -12,6 +12,8 @@ * Orbit's internal pages (`orbit:///`, DT-13) are Orbit's UI in a tab, not web pages: in the * main frame of one, and only there, this gives the bridge Orbit's UI has (D53), and patches * nothing. See `exposeInternalPage`. + * + * In every page's main frame, it also tells main whether the page used an Esc (D59, `watchEscape`). */ import { contextBridge, ipcRenderer } from 'electron' import type { OrbitBridge, StatePatch } from '../shared/rpc' @@ -190,6 +192,27 @@ function exposeInternalPage(windowId: string): void { contextBridge.exposeInMainWorld('orbit', bridge) } +/** + * Esc on a loading page (KS-19, D59): once every handler of the page has had a trusted Esc, main + * learns one bit, whether the page used it (preventDefault). Main stops the load only if it didn't. + * Runs in this preload's own world: nothing is added to the page, and nothing else is sent. + */ +function watchEscape(): void { + if (window.top !== window) return + window.addEventListener( + 'keydown', + (event) => { + if (!event.isTrusted || event.key !== 'Escape') return + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return + // After the whole dispatch: the page's handlers have all run. + setTimeout(() => ipcRenderer.send(PAGE_CHANNEL.escape, event.defaultPrevented === true), 0) + }, + true, + ) +} + +watchEscape() + if (location.protocol === 'orbit:') { // An internal page. Main says no (null) to anything but a tab's main frame showing one. if (window.top === window) { diff --git a/src/renderer/src/keys/ShortcutSheet.tsx b/src/renderer/src/keys/ShortcutSheet.tsx new file mode 100644 index 00000000..80c62a68 --- /dev/null +++ b/src/renderer/src/keys/ShortcutSheet.tsx @@ -0,0 +1,94 @@ +import { X } from 'lucide-react' +import { useEffect, useRef } from 'react' +import { invoke } from '../api' +import { useOrbit } from '../store' +import { useKeymap } from './live' +import { sheetGroups } from './sheet' +import './sheet.css' + +const close = () => invoke('overlay.close', {}) + +/** + * Help → Keyboard Shortcuts (OB-12): a compact sheet of the essential shortcuts over the window, + * in the overlay, with the keys they have now (KS-18). Esc, ✕ or a click outside closes it; the + * link at the bottom opens Settings → Shortcuts for the full, editable list. + */ +export function ShortcutSheet({ dark = false }: { dark?: boolean }) { + useKeymap() + const lastTabOnCmd9 = useOrbit((s) => s.settings.lastTabOnCmd9) + const groups = sheetGroups(window.orbit.platform, { lastTabOnCmd9 }) + const sheet = useRef(null) + + // The overlay has the keyboard while the sheet is up (KS-19): Esc closes it wherever focus is. + useEffect(() => { + sheet.current?.focus() + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape' || e.defaultPrevented) return + e.preventDefault() + void close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + return ( +
+
+
+

Keyboard Shortcuts

+ +
+
+ {groups.map((group) => ( +
+

{group.title}

+
+ {group.rows.map((row) => ( +
+
{row.title}
+
+ {row.keys ? ( + {row.keys} + ) : ( + None + )} +
+
+ ))} +
+
+ ))} +
+
+ +
+
+
+ ) +} diff --git a/src/renderer/src/keys/ShortcutsPage.tsx b/src/renderer/src/keys/ShortcutsPage.tsx new file mode 100644 index 00000000..04e5d111 --- /dev/null +++ b/src/renderer/src/keys/ShortcutsPage.tsx @@ -0,0 +1,723 @@ +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, + type ActionDef, + borrowedShortcutsFor, + formatAccelerator, + isFixed, + 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) } +} + +/** Keys another action lends this one, said under its row (⌘O for Open File where there is no Peek, KS-12). */ +function lentKeysNote(action: ActionDef): string | undefined { + const lent = borrowedShortcutsFor(action, platform) + if (!lent) return undefined + return `Also ${lent.keys.map(keys).join(' or ')} (the keys of ${lent.from.title}), ${lent.where}.` +} + +/** 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 || isFixed(entry.action, platform)) 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 && ( + +