diff --git a/.claude/skills/verify-berthd/SKILL.md b/.claude/skills/verify-berthd/SKILL.md index 23da1162..d484f15e 100644 --- a/.claude/skills/verify-berthd/SKILL.md +++ b/.claude/skills/verify-berthd/SKILL.md @@ -50,10 +50,12 @@ A scenario is a module: ```js export const plugins = [{ id: "counts", name: "Counts", source: `export default function (berth) { … }` }]; export const allowErrors = [/count boom/]; +export const init = (layout) => { /* runs in the page before the app, with initArg */ }; +export const initArg = { BracketLeft: "å" }; export async function run({ page, shot, step, expect }) { … } ``` -`expect` is Playwright's, so assertions wait as they do in the e2e suite. Use stable hooks: `data-testid` (`nav-`, `nav-more`, `nav-badge`, `home-grid`, the list at the top of `app/e2e/fixtures.ts`), roles and accessible names. Never coordinates. +`init`, when there is one, runs in the page before the app's own scripts, with `initArg` (Playwright's `addInitScript`): it stands in for what the browser would say, such as a keyboard layout (`keyboard-layout`). `expect` is Playwright's, so assertions wait as they do in the e2e suite. Use stable hooks: `data-testid` (`nav-`, `nav-more`, `nav-badge`, `home-grid`, the list at the top of `app/e2e/fixtures.ts`), roles and accessible names. Never coordinates. Which scenario proves which feature is in [features/README.md](features/README.md). diff --git a/.claude/skills/verify-berthd/features/README.md b/.claude/skills/verify-berthd/features/README.md index 4db31eba..0601003a 100644 --- a/.claude/skills/verify-berthd/features/README.md +++ b/.claude/skills/verify-berthd/features/README.md @@ -14,6 +14,7 @@ The maintained list of what a person can do in the app, and how to prove each on | --- | --- | --- | | Sidebar places: Pinned, More, Hidden, counts on rows (Home's waiting, Review's, a plugin's) | [sidebar-nav.md](sidebar-nav.md) | `plugin-nav-badges` | | Back and forward: the window strip's arrows, ⌘[ and ⌘], the mouse's buttons; archiving or removing the worktree in front goes back, not Home; the dashboard keeps its scroll | [navigation-history.md](navigation-history.md) | `navigation-history`; `app/e2e/back-forward.spec.ts` asserts it | +| Shortcuts in the keyboard's layout (⌘Å and ⌘¨ for Back and Forward on Norwegian) in tooltips, the Keyboard shortcuts sheet, Settings and ⌘K; the sheet's descriptions fit their lines | [navigation-history.md](navigation-history.md) (`history-layout`) | `keyboard-layout`, `keyboard-layout-us`; `app/e2e/back-forward.spec.ts` asserts it | | Stopping agents from the Agent Dashboard: one, the picked ones, a column, Clean up; archiving their worktrees where it's safe | [dashboard-stop.md](dashboard-stop.md) | `dashboard-stop-archive`; `app/e2e/dashboard-stop-archive.spec.ts` asserts it | | Plugins: built-in and your own, what they add (screens, sidebar items, widgets, panels) | [plugins.md](plugins.md) | `plugin-nav-badges` (sidebar items) | | Home's widget grid | [home-widgets.md](home-widgets.md) | none yet; `app/e2e/home-widgets.spec.ts` asserts it | diff --git a/.claude/skills/verify-berthd/features/navigation-history.md b/.claude/skills/verify-berthd/features/navigation-history.md index f45f88c9..54324be9 100644 --- a/.claude/skills/verify-berthd/features/navigation-history.md +++ b/.claude/skills/verify-berthd/features/navigation-history.md @@ -6,6 +6,7 @@ The app keeps a trail of the places you've been, as a browser does (`app/src/lib - `history-arrows`: Back and Forward in the window strip at the sidebar's top (rail and list layouts, `components/sidebar/back-forward.tsx`), before the bell and the fold. Disabled with nowhere to go. - `history-keys`: ⌘[ and ⌘] (Ctrl+[ and Ctrl+] in the Linux app; in a terminal there they stay the shell's, Ctrl+Shift+[ and ] are the app's). In a File tab's editor they stay outdent and indent. Also Go → Back and Go → Forward in the Mac app's menu bar, ⌘K's Back and Forward, and the Keyboard shortcuts sheet (all from `lib/shortcuts.json`). +- `history-layout`: the keys are shown in the keyboard's layout. ⌘[ and ⌘] are matched by the key's place (`e.code`), so on a layout where [ and ] need ⌥ they're ⌘ and the keys in that place: ⌘Å and ⌘¨ on Norwegian, ⌘Ü and ⌘+ on German. The arrows' tooltips, the Keyboard shortcuts sheet, Settings → Shortcuts and ⌘K show that (`lib/key-layout.ts`, a table entry's `code`); the layout comes from `navigator.keyboard.getLayoutMap()` in Chromium, the Mac app's `key_layout` command (macOS's input source, `src-tauri/src/keys.rs`), and keys pressed in those places (`lib/keyboard-layout.ts`). Unknown: a US keyboard's keys. The sheet's descriptions each fit their line. - `history-mouse`: a mouse's back (button 3) and forward (4) buttons, on `mouseup`. - `history-skip`: Back and Forward pass over a worktree that has gone (archived or removed) and over the place you're on already. - `history-gone`: when the worktree in front is archived or removed and that would leave you on Home, you go back to the last place before it that is still there instead (the Agent Dashboard, say). With Labs tab groups, when another group comes forward it stays in front, as before. @@ -19,6 +20,7 @@ The app keeps a trail of the places you've been, as a browser does (`app/src/lib ## Driving it with drive.mjs - `node .claude/skills/verify-berthd/scripts/drive.mjs navigation-history --port $PORT` starts on Home (both arrows disabled), opens the dashboard with ⌘J and scrolls it down, clicks "Speed up product search" (worktree `search-perf` on `devl`), then: Back to the dashboard at the same `scrollTop`, Forward to the session, ⌘[ and ⌘] the same, mouse buttons 3 and 4 the same, ⌘W to close its tab, **Archive this worktree…** and **Archive** in the confirm: it asserts the dashboard shows (not Home), the card is gone and Forward is disabled. +- `node .claude/skills/verify-berthd/scripts/drive.mjs keyboard-layout --port $PORT` stubs a Norwegian `getLayoutMap()` (the scenario's `init`): after ⌘J, Back's tooltip reads "Back (⌘Å)"; ⌘[ (the key's place) still goes back and Forward's reads "Forward (⌘¨)"; ⌘/ shows ⌘Å and ⌘¨ in the sheet and checks no description there is cut off (`scrollWidth` against `clientWidth`) or ends in "…"; Settings → Shortcuts finds Back by "⌘å" and Go to file… by its menu name; ⌘K's Back shows ⌘Å. `keyboard-layout-us` runs the same with a US layout: ⌘[ and ⌘]. - With Labs on (tab groups, the harbour Home): a two-line scenario that re-exports `run` from `navigation-history.mjs` with `export const params = { labs: "1" }` passes the same steps. - Hooks: `history-back`, `history-forward` (the arrows, in a group named "History"), `dashboard-board` (the board's scroller). The worktree in front is the page's `h1` (App.tsx `WorkspaceHeading`, or the launcher's title). @@ -26,5 +28,7 @@ The app keeps a trail of the places you've been, as a browser does (`app/src/lib - Playwright's mouse has no back or forward button: the scenario dispatches `mouseup` with `button` 3 and 4 on the window, which is what the handler listens for. A real mouse in the Mac app (WKWebView) needs a person. - The Mac app's menu items and the ⌘[ that reaches a File tab's editor while the menu holds the key can't be shown on the mock; `hooks/use-shortcuts.ts` hands the key to the editor when it has the keyboard. +- An arrow with nowhere to go is disabled and shows no tooltip on hover: go somewhere first (⌘J), as the scenarios do. Its tooltip used to stay away for good once it was enabled; `Tip` now remounts the tooltip when its child's `disabled` flips. +- The Mac app's real layout (`key_layout` asking macOS) and its Go menu need a person on a Mac: the mock stubs `getLayoutMap()`. Whether the Go menu reads ⌘Å is macOS's doing (automatic key equivalent localization); the menu items come from `accel` and still say ⌘[ to muda. `keys.rs` has a test that reads the US, Norwegian, German and Dvorak layouts, which runs on a Mac (`cargo test`). - The folded sidebar, zen and Settings have no arrows; the keys, the mouse, ⌘K and the menu still work there. - The mock's `shop` project has an archive script: the box answers at once, the launcher shows Archiving…, and the worktree goes (and you go back) 1.5s later, when `worktree.removed` arrives. Remove worktree, from the sidebar's ⋯, goes the same way, through `forgetWorktree`. diff --git a/.claude/skills/verify-berthd/scripts/drive.mjs b/.claude/skills/verify-berthd/scripts/drive.mjs index c42d9fdf..4917aae9 100755 --- a/.claude/skills/verify-berthd/scripts/drive.mjs +++ b/.claude/skills/verify-berthd/scripts/drive.mjs @@ -56,6 +56,9 @@ const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" }); // The laptop agent's ports: a run must never reach this machine's own. await context.route(/^https?:\/\/(?:[^/]*\.)?(?:localhost|127\.0\.0\.1|\[::1\]):(?:1377|1378|1379)(?:\/|$)/, (r) => r.abort()); +// What the scenario stands in for before the app's own scripts run (a +// keyboard layout, say), in the page, with initArg. +if (scenario.init) await context.addInitScript(scenario.init, scenario.initArg); const page = await context.newPage(); page.on("console", (m) => { log.push(redact(`[${m.type()}] ${m.text()}`)); diff --git a/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout-us.mjs b/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout-us.mjs new file mode 100644 index 00000000..d4a60aaa --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout-us.mjs @@ -0,0 +1,6 @@ +// keyboard-layout.mjs on a US layout: Back and Forward read ⌘[ and ⌘]. +import { drive, init, LAYOUTS } from "./keyboard-layout.mjs"; + +export { init }; +export const initArg = LAYOUTS.us.map; +export const run = drive(LAYOUTS.us); diff --git a/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout.mjs b/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout.mjs new file mode 100644 index 00000000..1d159eb6 --- /dev/null +++ b/.claude/skills/verify-berthd/scripts/scenarios/keyboard-layout.mjs @@ -0,0 +1,83 @@ +// Shortcuts in the keyboard's layout (app/src/lib/key-layout.ts): Back and +// Forward are matched by the key's place, so on a Norwegian keyboard they +// read ⌘Å and ⌘¨ wherever keys are shown, and the keys still work. Every +// description fits its line in the Keyboard shortcuts sheet. +// features/navigation-history.md (history-layout). keyboard-layout-us.mjs +// runs the same on a US layout. + +export const LAYOUTS = { + no: { name: "Norwegian", map: { BracketLeft: "å", BracketRight: "¨", KeyD: "d" }, back: "⌘Å", forward: "⌘¨" }, + us: { name: "US", map: { BracketLeft: "[", BracketRight: "]", KeyD: "d" }, back: "⌘[", forward: "⌘]" }, +}; + +// init stands in for Chromium's navigator.keyboard.getLayoutMap() with map. +export function init(map) { + const layout = new Map(Object.entries(map)); + Object.defineProperty(navigator, "keyboard", { configurable: true, value: { getLayoutMap: async () => layout } }); +} +export const initArg = LAYOUTS.no.map; + +export function drive(l) { + return async ({ page, shot, step, expect }) => { + const back = page.getByTestId("history-back"); + const forward = page.getByTestId("history-forward"); + const tip = page.locator("[data-slot=tooltip-popup]"); + const dashboard = page.getByRole("heading", { name: "Agent Dashboard" }); + + step(`${l.name} layout, ⌘J to the dashboard: Back's tooltip reads "Back (${l.back})"`); + await page.keyboard.press("Meta+j"); + await expect(dashboard).toBeVisible(); + await back.hover(); + await expect(tip).toHaveText(`Back (${l.back})`); + await shot("1-back-tooltip"); + + step("the keys are as they were: ⌘ and the key right of P goes back, and Forward's tooltip reads its key"); + await page.keyboard.press("Meta+BracketLeft"); + await expect(dashboard).toBeHidden(); + await expect(back).toBeDisabled(); + await forward.hover(); + await expect(tip).toHaveText(`Forward (${l.forward})`); + await shot("2-forward-tooltip"); + await page.keyboard.press("Meta+BracketRight"); + await expect(dashboard).toBeVisible(); + + step(`⌘/ opens Keyboard shortcuts: Back is ${l.back}, Forward ${l.forward}, and no description is cut off`); + await page.keyboard.press("Meta+/"); + const sheet = page.getByRole("dialog", { name: "Keyboard shortcuts" }); + await expect(sheet).toBeVisible(); + const row = (text) => sheet.getByRole("listitem").filter({ hasText: text }); + await expect(row("Back to where you were")).toContainText(l.back); + await expect(row("Forward again, after Back")).toContainText(l.forward); + await expect(row("Search and commands")).toContainText("⌘K"); + const lines = await sheet.locator("li .truncate").evaluateAll((els) => els.map((el) => ({ text: el.textContent, cut: el.scrollWidth > el.clientWidth }))); + const cut = lines.filter((x) => x.cut || x.text.endsWith("…")).map((x) => x.text); + if (lines.length < 30 || cut.length) throw new Error(`descriptions cut off (${lines.length} lines): ${JSON.stringify(cut)}`); + step(` ${lines.length} descriptions, none cut off; the longest: "${lines.map((x) => x.text).sort((a, b) => b.length - a.length)[0]}"`); + await shot("3-shortcuts-sheet"); + await sheet.getByRole("region", { name: "Go" }).scrollIntoViewIfNeeded(); + await shot("4-shortcuts-sheet-go"); + + step(`Search them in Settings: "${l.back.toLowerCase()}" finds Back, and "go to file" finds Go to file… by its menu name`); + await sheet.getByRole("button", { name: "Search them in Settings" }).click(); + const settings = page.getByTestId("settings-shortcuts"); + const search = settings.getByRole("textbox", { name: "Search shortcuts" }); + await expect(search).toBeVisible(); + await search.fill(l.back.toLowerCase()); + await expect(settings.getByText("Back to where you were")).toBeVisible(); + await expect(settings.getByText("Forward again, after Back")).toHaveCount(0); + await shot("5-settings-search-keys"); + await search.fill("go to file"); + await expect(settings.getByText("Find a file in the worktree")).toBeVisible(); + await shot("6-settings-search-label"); + + step(`⌘K, "back": its Back item shows ${l.back}`); + await page.keyboard.press("Meta+k"); + const palette = page.getByRole("dialog").filter({ has: page.getByRole("combobox") }); + await page.keyboard.type("back"); + const item = palette.getByRole("option").filter({ hasText: /^Back/ }).first(); + await expect(item).toContainText(l.back); + await shot("7-palette-back"); + }; +} + +export const run = drive(LAYOUTS.no); diff --git a/app/e2e/back-forward.spec.ts b/app/e2e/back-forward.spec.ts index cec909bd..019c0f9f 100644 --- a/app/e2e/back-forward.spec.ts +++ b/app/e2e/back-forward.spec.ts @@ -17,6 +17,9 @@ test("Back and Forward walk the places you've been, and archiving the worktree i await expect(forward).toBeDisabled(); await page.keyboard.press("Meta+j"); await expect(dashboard).toBeVisible(); + // The browser's own layout here is US's, or not known: a US keyboard's keys. + await back.hover(); + await expect(page.locator("[data-slot=tooltip-popup]")).toHaveText("Back (⌘[)"); await page.getByTestId("dashboard-board").locator("article", { hasText: "Speed up product search" }).click(); await expect(inWorktree).toBeAttached(); @@ -38,3 +41,45 @@ test("Back and Forward walk the places you've been, and archiving the worktree i await expect(inWorktree).toHaveCount(0); await expect(forward).toBeDisabled(); }); + +// Back and Forward are matched by the key's place, so on a layout where [ +// and ] need ⌥ they show the keys in that place (lib/key-layout.ts): here +// Norwegian's, as navigator.keyboard.getLayoutMap() says it. +test("on a Norwegian keyboard, Back and Forward show ⌘Å and ⌘¨, and every description fits its line", async ({ app }) => { + mockOnly(); + await app.context.addInitScript(() => { + const w = window as unknown as { __layout: [string, string][] }; + w.__layout = [["BracketLeft", "å"], ["BracketRight", "¨"], ["KeyD", "d"]]; + const keyboard = Object.assign(new EventTarget(), { getLayoutMap: async () => new Map(w.__layout) }); + Object.defineProperty(navigator, "keyboard", { configurable: true, value: keyboard }); + }); + await app.open(); + const page = app.page; + const tip = page.locator("[data-slot=tooltip-popup]"); + + // An arrow with nowhere to go has no tooltip: go somewhere, then back. + await page.keyboard.press("Meta+j"); + await page.getByTestId("history-back").hover(); + await expect(tip).toHaveText("Back (⌘Å)"); + await page.keyboard.press("Meta+BracketLeft"); + await page.getByTestId("history-forward").hover(); + await expect(tip).toHaveText("Forward (⌘¨)"); + + await page.keyboard.press("Meta+/"); + const sheet = page.getByRole("dialog", { name: "Keyboard shortcuts" }); + await expect(sheet.getByRole("listitem").filter({ hasText: "Back to where you were" })).toContainText("⌘Å"); + await expect(sheet.getByRole("listitem").filter({ hasText: "Forward again, after Back" })).toContainText("⌘¨"); + await expect(sheet.getByRole("listitem").filter({ hasText: "Search and commands" })).toContainText("⌘K"); + const cut = await sheet.locator("li .truncate").evaluateAll((els) => els.filter((el) => el.scrollWidth > el.clientWidth).map((el) => el.textContent)); + expect(cut).toEqual([]); + + // Switched to a US layout with the window in front all along: the sheet + // asks again as it opens. + await page.keyboard.press("Escape"); + await expect(sheet).toHaveCount(0); + await page.evaluate(() => { + (window as unknown as { __layout: [string, string][] }).__layout = [["BracketLeft", "["], ["BracketRight", "]"]]; + }); + await page.keyboard.press("Meta+/"); + await expect(sheet.getByRole("listitem").filter({ hasText: "Back to where you were" })).toContainText("⌘["); +}); diff --git a/app/src-tauri/src/keys.rs b/app/src-tauri/src/keys.rs new file mode 100644 index 00000000..eeae7701 --- /dev/null +++ b/app/src-tauri/src/keys.rs @@ -0,0 +1,196 @@ +// What keys type on the keyboard layout in use, for the shortcuts the +// webview matches by the key's place rather than its character (Back is ⌘ +// and the key right of P: [ on a US layout, Å on a Norwegian one). WKWebView +// has no navigator.keyboard.getLayoutMap(), so the Mac app asks macOS: the +// input source's keyboard layout (Text Input Sources), and UCKeyTranslate +// for what each key types there with no modifier. A dead key (Norwegian ¨) +// gives its mark on its own. The webview shows these in place of a US +// keyboard's keys (app/src/lib/keyboard-layout.ts). + +use std::collections::HashMap; + +// labels is what each of codes (KeyboardEvent.code, "BracketLeft") types on +// the layout in use. A code it doesn't know, or that types nothing it can +// show, is left out; off a Mac, all of them are, and the webview learns the +// layout as keys are pressed instead. +pub fn labels(codes: &[String]) -> HashMap { + #[cfg(target_os = "macos")] + { + mac::current(codes) + } + #[cfg(not(target_os = "macos"))] + { + let _ = codes; + HashMap::new() + } +} + +#[cfg(target_os = "macos")] +mod mac { + use std::collections::HashMap; + use std::ffi::c_void; + + type CFTypeRef = *const c_void; + type UniCharCount = std::os::raw::c_ulong; + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + pub(super) static kTISPropertyUnicodeKeyLayoutData: CFTypeRef; + fn TISCopyCurrentKeyboardLayoutInputSource() -> CFTypeRef; + pub(super) fn TISGetInputSourceProperty(source: CFTypeRef, key: CFTypeRef) -> CFTypeRef; + fn LMGetKbdType() -> u8; + fn UCKeyTranslate( + layout: *const u8, + virtual_key_code: u16, + key_action: u16, + modifier_key_state: u32, + keyboard_type: u32, + key_translate_options: u32, + dead_key_state: *mut u32, + max_string_length: UniCharCount, + actual_string_length: *mut UniCharCount, + unicode_string: *mut u16, + ) -> i32; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + pub(super) fn CFDataGetBytePtr(data: CFTypeRef) -> *const u8; + pub(super) fn CFRelease(cf: CFTypeRef); + } + + // mac_key is the macOS virtual key code (Events.h, kVK_ANSI_*) of a + // KeyboardEvent.code: the same physical key. + fn mac_key(code: &str) -> Option { + Some(match code { + "BracketLeft" => 0x21, + "BracketRight" => 0x1E, + "KeyC" => 0x08, + "KeyD" => 0x02, + "KeyI" => 0x22, + "KeyS" => 0x01, + _ => return None, + }) + } + + const KEY_ACTION_DISPLAY: u16 = 3; + const NO_DEAD_KEYS: u32 = 1; + + // current reads the keyboard layout of the input source in use: an + // input method's (Japanese, Pinyin) too, as it types letters with ⌘. + // Text Input Sources wants the main thread, where Tauri runs a command + // that isn't async. + pub fn current(codes: &[String]) -> HashMap { + unsafe { + let source = TISCopyCurrentKeyboardLayoutInputSource(); + if source.is_null() { + return HashMap::new(); + } + let out = from_source(source, codes); + CFRelease(source); + out + } + } + + // from_source is labels for an input source. + pub(super) unsafe fn from_source(source: CFTypeRef, codes: &[String]) -> HashMap { + let mut out = HashMap::new(); + let data = TISGetInputSourceProperty(source, kTISPropertyUnicodeKeyLayoutData); + if data.is_null() { + return out; + } + let layout = CFDataGetBytePtr(data); + let kbd = LMGetKbdType() as u32; + for code in codes { + let Some(key) = mac_key(code) else { + continue; + }; + let mut dead = 0u32; + let mut len: UniCharCount = 0; + let mut buf = [0u16; 4]; + let status = UCKeyTranslate( + layout, + key, + KEY_ACTION_DISPLAY, + 0, + kbd, + NO_DEAD_KEYS, + &mut dead, + buf.len() as UniCharCount, + &mut len, + buf.as_mut_ptr(), + ); + if status != 0 || len == 0 { + continue; + } + if let Ok(s) = String::from_utf16(&buf[..len as usize]) { + if !s.trim().is_empty() { + out.insert(code.clone(), s); + } + } + } + out + } +} + +// A layout by its input source id, installed on every Mac whether it is in +// use or not: what the keys type there, without switching to it. +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::mac::{CFRelease, TISGetInputSourceProperty}; + use std::collections::HashMap; + use std::ffi::c_void; + + type CFTypeRef = *const c_void; + + #[link(name = "Carbon", kind = "framework")] + extern "C" { + static kTISPropertyInputSourceID: CFTypeRef; + fn TISCreateInputSourceList(properties: CFTypeRef, include_all_installed: bool) -> CFTypeRef; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + static kCFTypeDictionaryKeyCallBacks: c_void; + static kCFTypeDictionaryValueCallBacks: c_void; + fn CFStringCreateWithBytes(alloc: CFTypeRef, bytes: *const u8, len: isize, encoding: u32, external: bool) -> CFTypeRef; + fn CFDictionaryCreate(alloc: CFTypeRef, keys: *const CFTypeRef, values: *const CFTypeRef, n: isize, kcb: *const c_void, vcb: *const c_void) -> CFTypeRef; + fn CFArrayGetCount(a: CFTypeRef) -> isize; + fn CFArrayGetValueAtIndex(a: CFTypeRef, i: isize) -> CFTypeRef; + } + + fn on(id: &str) -> HashMap { + let codes: Vec = ["BracketLeft", "BracketRight", "KeyD", "Escape"].map(String::from).to_vec(); + unsafe { + let id = CFStringCreateWithBytes(std::ptr::null(), id.as_ptr(), id.len() as isize, 0x0800_0100, false); + let key = kTISPropertyInputSourceID; + let filter = CFDictionaryCreate(std::ptr::null(), &key, &id, 1, &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks); + let list = TISCreateInputSourceList(filter, true); + assert!(!list.is_null() && CFArrayGetCount(list) > 0, "no input source {id:?}"); + let source = CFArrayGetValueAtIndex(list, 0); + assert!(!TISGetInputSourceProperty(source, super::mac::kTISPropertyUnicodeKeyLayoutData).is_null()); + let out = super::mac::from_source(source, &codes); + CFRelease(list); + CFRelease(filter); + CFRelease(id); + out + } + } + + fn labels(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + #[test] + fn keys_in_their_place_on_each_layout() { + assert_eq!(on("com.apple.keylayout.US"), labels(&[("BracketLeft", "["), ("BracketRight", "]"), ("KeyD", "d")])); + assert_eq!(on("com.apple.keylayout.Norwegian"), labels(&[("BracketLeft", "å"), ("BracketRight", "¨"), ("KeyD", "d")])); + assert_eq!(on("com.apple.keylayout.German"), labels(&[("BracketLeft", "ü"), ("BracketRight", "+"), ("KeyD", "d")])); + assert_eq!(on("com.apple.keylayout.Dvorak"), labels(&[("BracketLeft", "/"), ("BracketRight", "="), ("KeyD", "e")])); + } + + #[test] + fn the_layout_in_use_says_something() { + assert!(super::mac::current(&["KeyD".to_string()]).contains_key("KeyD")); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index bec3e3c8..1444e08a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ use tauri::Manager; mod agent; mod browser; mod cli_link; +mod keys; #[cfg(target_os = "linux")] mod linux; mod menu; @@ -88,6 +89,15 @@ fn open_terminal() -> Result<(), String> { } } +// key_layout is what each of codes (KeyboardEvent.code) types on the +// keyboard layout in use (keys.rs), so the shortcuts the webview matches by a +// key's place show that key. Not async: Tauri runs it on the main thread, +// which macOS's Text Input Sources wants. +#[tauri::command] +fn key_layout(codes: Vec) -> std::collections::HashMap { + keys::labels(&codes) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { #[cfg(target_os = "linux")] @@ -135,6 +145,7 @@ pub fn run() { open_devtools, restart_app, open_terminal, + key_layout, cli_link::cli_link_status, cli_link::install_cli_link, agent::agent_binary, diff --git a/app/src/App.tsx b/app/src/App.tsx index 287b4be0..3e98633f 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -43,6 +43,7 @@ import { startBoxDefaultsSync } from "@/lib/box-defaults"; import { startSkillsNoticeWatch } from "@/lib/skills-notice"; import { startRunsWatch } from "@/lib/runs"; import { watchStillness } from "@/lib/still"; +import { watchKeyboardLayout } from "@/lib/keyboard-layout"; import { useStore } from "@/lib/store"; import { startUpdater } from "@/lib/updater"; import { useWhatsNewAfterUpdate } from "@/lib/whats-new"; @@ -85,6 +86,8 @@ export default function App() { useApplyTheme(); useBerthConnection(); useShortcuts(); + // The keyboard layout, so the keys shown are the keys to press there. + useEffect(watchKeyboardLayout, []); useWindowTitle(); // Checks for a newer Shipyard on launch and every few hours (lib/updater.ts). useEffect(startUpdater, []); diff --git a/app/src/components/command-palette.tsx b/app/src/components/command-palette.tsx index e2ac45fc..aff3943c 100644 --- a/app/src/components/command-palette.tsx +++ b/app/src/components/command-palette.tsx @@ -61,7 +61,7 @@ import { sessionWord } from "@/lib/state-model"; import { useThemes } from "@/hooks/use-theme"; import { openBrowserAt, openPreviewAt, resolveUrl } from "@/lib/actions"; import { FROM_TABLE } from "@/lib/palette-shortcuts"; -import { describe, keysFor, SHORTCUTS } from "@/lib/shortcuts"; +import { describe, keysFor, keysOf, SHORTCUTS } from "@/lib/shortcuts"; import { runShortcut } from "@/hooks/use-shortcuts"; import { openRenameWorktree } from "@/components/sidebar/rename-worktree"; import type { SettingsSectionId } from "@/views/settings/open"; @@ -71,6 +71,7 @@ import { quietNow, setDoNotDisturb, setNotificationsOpen } from "@/lib/notificat import { usePrefs } from "@/lib/prefs"; import { useStore } from "@/lib/store"; import { isLink, teamRef } from "@/lib/team-ref"; +import { useKeyLabels } from "@/lib/keyboard-layout"; import { loginUrl, loginUserLabel, useLoginConfig, worktreeOrigin } from "@/lib/login-users"; import { openReviewSheet } from "@/lib/pr-review"; import { parseReviewRef } from "@/lib/review-link"; @@ -147,6 +148,8 @@ const slot = (icon: React.ReactNode) => ), shortcut: keysOf(k, keyLabels), run: go(() => void runShortcut(id, "menu")) }]; }), { value: "worktrees", label: "Worktrees", icon: slot(), run: go(() => st.setView({ kind: "worktrees" })) }, { value: "automations", label: "Automations", icon: slot(), run: go(() => st.setView({ kind: "automations" })) }, @@ -404,7 +407,7 @@ export function CommandPalette() { ].filter((g) => g.items.length); // close is stable enough: it only reads refs and store setters. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [sessions, boxes, status, themes, themeId, spaces, pluginCommands, query, nav.hidden, open, login]); + }, [sessions, boxes, status, themes, themeId, spaces, pluginCommands, query, nav.hidden, open, login, keyLabels]); shown.current = { groups, query }; return ( diff --git a/app/src/components/shortcuts-sheet.tsx b/app/src/components/shortcuts-sheet.tsx index 9ffd9a74..ac8d83f9 100644 --- a/app/src/components/shortcuts-sheet.tsx +++ b/app/src/components/shortcuts-sheet.tsx @@ -8,7 +8,8 @@ import { Sheet, SheetDescription, SheetFooter, SheetHeader, SheetPanel, SheetPop import { isTauri } from "@/lib/api"; import { IS_LINUX, LINUX_TERMINAL_KEYS } from "@/lib/platform"; import { usePrefs } from "@/lib/prefs"; -import { describe, SHORTCUT_GROUPS, SHORTCUTS } from "@/lib/shortcuts"; +import { useKeyLabels } from "@/lib/keyboard-layout"; +import { describe, keysOf, SHORTCUT_GROUPS, SHORTCUTS } from "@/lib/shortcuts"; import { useStore } from "@/lib/store"; // The Keyboard shortcuts sheet (Help → Keyboard shortcuts, ⌘/, or ⌘K): every @@ -21,6 +22,7 @@ export const openShortcuts = () => useShortcutsSheet.setState({ open: true }); export function ShortcutsSheet() { const open = useShortcutsSheet((s) => s.open); const labs = usePrefs((p) => p.labs); + const labels = useKeyLabels(open); const close = () => useShortcutsSheet.setState({ open: false }); return ( useShortcutsSheet.setState({ open: o })}> @@ -49,7 +51,7 @@ export function ShortcutsSheet() {
  • {describe(s)} {s.labs && !labs && Labs} - {s.keys} + {keysOf(s, labels)}
  • ))} diff --git a/app/src/components/sidebar/back-forward.tsx b/app/src/components/sidebar/back-forward.tsx index 62616f5f..cc2adcdf 100644 --- a/app/src/components/sidebar/back-forward.tsx +++ b/app/src/components/sidebar/back-forward.tsx @@ -2,6 +2,7 @@ import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { ink } from "@/components/sidebar/ink"; import { Tip } from "@/components/tip"; +import { askKeyboardLayout, useKeyLabels } from "@/lib/keyboard-layout"; import { go, useCanGo } from "@/lib/nav-history"; import { keysFor } from "@/lib/shortcuts"; import { cn } from "@/lib/utils"; @@ -19,14 +20,17 @@ export function BackForward() { function Arrow({ dir, id, label, icon }: { dir: -1 | 1; id: string; label: string; icon: React.ReactNode }) { const can = useCanGo(dir); + const keys = keysFor(id, useKeyLabels()); return ( - +