From 1b3bc405a7b05ccb7a9396294168d811186d1427 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 12:24:36 +0100 Subject: [PATCH 1/6] feat(web): rebindable keyboard shortcuts and a Keybinds settings page - core/lib/keybindings: a Schema-typed KeyCombo (canonical, Mod-portable), and defineKeymap: actions, overrides, resolve/assign/unbind/reset with conflict handling, and Foldkit bindings per subscription - app-keymap replaces hotkey-registry and shortcut-catalog; composer and message keys are listed as fixed - overrides live at the root (per device, like sound settings) and reach the layout and channel hotkeys, the Mod+/ dialog and the sidebar hints - My Settings > Keybinds: record a key by pressing it, live modifiers, search, reset per row and reset all, moved-key and shadowed-key notices Co-Authored-By: Claude Opus 5.5 --- apps/web-foldkit/src/app/command.ts | 12 + .../web-foldkit/src/app/hotkeys.story.test.ts | 16 +- apps/web-foldkit/src/app/loading.test.ts | 2 + apps/web-foldkit/src/app/message.ts | 2 + apps/web-foldkit/src/app/model.ts | 10 + .../src/app/out-messages.story.test.ts | 36 +- apps/web-foldkit/src/app/subscription.ts | 8 +- apps/web-foldkit/src/app/view.ts | 2 + .../core/lib/hotkeys/hotkey-registry.test.ts | 29 -- .../src/core/lib/hotkeys/hotkey-registry.ts | 183 ---------- .../src/core/lib/hotkeys/shortcut-catalog.ts | 85 ----- .../src/core/lib/keybindings/app-keymap.ts | 325 +++++++++++++++++ .../core/lib/keybindings/key-combo.test.ts | 86 +++++ .../src/core/lib/keybindings/key-combo.ts | 220 ++++++++++++ .../src/core/lib/keybindings/keymap.test.ts | 88 +++++ .../src/core/lib/keybindings/keymap.ts | 203 +++++++++++ apps/web-foldkit/src/icons/index.ts | 35 ++ apps/web-foldkit/src/keybindings.ts | 20 ++ apps/web-foldkit/src/main.ts | 18 + apps/web-foldkit/src/overlay/hotkeys.test.ts | 39 ++- apps/web-foldkit/src/overlay/hotkeys.ts | 27 +- .../src/overlay/modal/keyboard-shortcuts.ts | 65 +++- apps/web-foldkit/src/overlay/toaster.test.ts | 2 + .../src/page/chat/channel/hotkeys.ts | 66 ++-- .../src/page/chat/channel/subscription.ts | 8 +- apps/web-foldkit/src/page/contract.ts | 8 + .../src/page/my-settings/keybinds/index.ts | 13 + .../src/page/my-settings/keybinds/message.ts | 20 ++ .../src/page/my-settings/keybinds/model.ts | 25 ++ .../my-settings/keybinds/recorder.test.ts | 54 +++ .../page/my-settings/keybinds/scene.test.ts | 151 ++++++++ .../page/my-settings/keybinds/subscription.ts | 82 +++++ .../src/page/my-settings/keybinds/update.ts | 82 +++++ .../src/page/my-settings/keybinds/view.ts | 331 ++++++++++++++++++ apps/web-foldkit/src/page/out-message.ts | 3 + apps/web-foldkit/src/page/registry.ts | 2 + apps/web-foldkit/src/page/test-shared.ts | 4 + apps/web-foldkit/src/route.test.ts | 5 + apps/web-foldkit/src/route.ts | 4 + .../web-foldkit/src/shell/channels-sidebar.ts | 21 +- .../channels-sidebar/create-channel-hint.ts | 28 +- .../shell/channels-sidebar/hotkey-label.ts | 58 +-- .../src/shell/channels-sidebar/view.ts | 39 ++- apps/web-foldkit/src/shell/context.ts | 3 + apps/web-foldkit/src/shell/sidebars.ts | 3 + apps/web-foldkit/src/test/root-fixtures.ts | 4 + apps/web-foldkit/src/theme.test.ts | 2 + 47 files changed, 2088 insertions(+), 441 deletions(-) delete mode 100644 apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.test.ts delete mode 100644 apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.ts delete mode 100644 apps/web-foldkit/src/core/lib/hotkeys/shortcut-catalog.ts create mode 100644 apps/web-foldkit/src/core/lib/keybindings/app-keymap.ts create mode 100644 apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts create mode 100644 apps/web-foldkit/src/core/lib/keybindings/key-combo.ts create mode 100644 apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts create mode 100644 apps/web-foldkit/src/core/lib/keybindings/keymap.ts create mode 100644 apps/web-foldkit/src/keybindings.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/index.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/message.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/model.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/recorder.test.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/subscription.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/update.ts create mode 100644 apps/web-foldkit/src/page/my-settings/keybinds/view.ts diff --git a/apps/web-foldkit/src/app/command.ts b/apps/web-foldkit/src/app/command.ts index 9214edd4d..4a69313d3 100644 --- a/apps/web-foldkit/src/app/command.ts +++ b/apps/web-foldkit/src/app/command.ts @@ -6,6 +6,8 @@ import { readAccessToken } from "~/lib/access-token" import { isRetryableError } from "~/lib/error-messages" import { catchCommon } from "../data/actions" import { saveSoundSettings, SoundSettings } from "../notification-sound" +import { saveKeybindingOverrides } from "../keybindings" +import { Overrides } from "~/lib/keybindings/keymap" import { clearNoteDrafts } from "../page/customers/detail/note-drafts" import { HazelRpc } from "../rpc" import { applyTheme, ResolvedTheme, saveThemePreference, ThemeCustomization, ThemePreference } from "../theme" @@ -74,6 +76,16 @@ export const SaveSoundSettings = Command.define("SaveSoundSettings", { ), }) +export const SaveKeybindings = Command.define("SaveKeybindings", { + args: { overrides: Overrides }, + messages: [Message.CompletedSaveKeybindings, Message.FailedSaveKeybindings], + execute: ({ overrides }) => + saveKeybindingOverrides(overrides).pipe( + Effect.as(Message.CompletedSaveKeybindings()), + Effect.catchTag("StorageWriteError", () => Effect.succeed(Message.FailedSaveKeybindings())), + ), +}) + /** `notificationOrchestrator.enqueue(events)`; it skips what it has already processed. */ export const DeliverNotifications = Command.define("DeliverNotifications", { args: { ids: Schema.Array(NotificationId) }, diff --git a/apps/web-foldkit/src/app/hotkeys.story.test.ts b/apps/web-foldkit/src/app/hotkeys.story.test.ts index 8317d20d1..9730b43a0 100644 --- a/apps/web-foldkit/src/app/hotkeys.story.test.ts +++ b/apps/web-foldkit/src/app/hotkeys.story.test.ts @@ -3,8 +3,7 @@ import { NotificationId, OrganizationId } from "@hazel/schema" import { Schema } from "effect" import { Command, given, message, model, story } from "foldkit/story" import { describe, expect, test } from "vitest" -import { HOTKEY_DEFINITIONS_BY_ID } from "~/lib/hotkeys/hotkey-registry" -import { SHORTCUT_SECTIONS } from "~/lib/hotkeys/shortcut-catalog" +import { appKeymap } from "~/lib/keybindings/app-keymap" import { update } from "../main" import { LayoutHotkeyActionId } from "../overlay/hotkeys" import * as ShellModel from "../shell/model" @@ -165,12 +164,11 @@ describe("Mod+[ / Mod+] (Slack)", () => { }) }) -describe("the Mod+/ dialog lists every bound layout hotkey", () => { - test("each layout action appears in the catalog with its default keys", () => { - const listed = new Set(SHORTCUT_SECTIONS.flatMap((section) => section.shortcuts.map((s) => s.label))) - // Mod+Alt+Shift+Arrows run the unread-channel step until there is mention data: not listed twice. - const aliases: ReadonlyArray = ["mention.previous", "mention.next"] - for (const actionId of LayoutHotkeyActionId.literals.filter((id) => !aliases.includes(id))) - expect(listed, actionId).toContain(HOTKEY_DEFINITIONS_BY_ID[actionId].label) +describe("the layout hotkeys are keymap actions", () => { + test("each layout action is rebindable and has a default key", () => { + for (const actionId of LayoutHotkeyActionId.literals) { + expect(appKeymap.byId[actionId].rebindable, actionId).toBe(true) + expect(appKeymap.byId[actionId].defaultKey, actionId).not.toBeNull() + } }) }) diff --git a/apps/web-foldkit/src/app/loading.test.ts b/apps/web-foldkit/src/app/loading.test.ts index 7fbccd1f7..7c438fad2 100644 --- a/apps/web-foldkit/src/app/loading.test.ts +++ b/apps/web-foldkit/src/app/loading.test.ts @@ -46,6 +46,8 @@ const boot = (path: string): Model => sessionStartMs: 0, isMobile: false, isCreateChannelHintDismissed: false, + keybindingOverrides: {}, + keyboardPlatform: "linux", }, url, ).model, diff --git a/apps/web-foldkit/src/app/message.ts b/apps/web-foldkit/src/app/message.ts index 2c064c7a1..405b9f896 100644 --- a/apps/web-foldkit/src/app/message.ts +++ b/apps/web-foldkit/src/app/message.ts @@ -40,6 +40,8 @@ export const Message = defineMessageUnion({ FailedSaveThemePreference: {}, CompletedSaveSoundSettings: {}, FailedSaveSoundSettings: {}, + CompletedSaveKeybindings: {}, + FailedSaveKeybindings: {}, /** The member's latest notifications (newest first), for the sound and native sinks. */ UpdatedRecentNotifications: { ids: Schema.Array(NotificationId) }, CompletedDeliverNotifications: {}, diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index f1f772460..39819db56 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -12,6 +12,9 @@ import { PageSlot } from "../page/registry" import { AppRoute, orgSectionOf, orgSlugOf } from "../route" import { type Auth, CurrentUser, Member, Organization } from "../session" import { SoundSettings } from "../notification-sound" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { Platform as KeyboardPlatform } from "~/lib/keybindings/key-combo" +import { Overrides } from "~/lib/keybindings/keymap" import * as Platform from "../platform" import * as Updates from "../platform/updates" import * as Shell from "../shell/model" @@ -86,6 +89,10 @@ export const Model = Schema.Struct({ systemTheme: ResolvedTheme, /** `notificationSoundSettingsAtom`, stored like legacy. */ soundSettings: SoundSettings, + /** The user's keybinding changes (`keybindings.ts`), stored per device. */ + keybindingOverrides: Overrides, + /** The keyboard's platform: what `Mod` means, and how keys are labeled. */ + keyboardPlatform: KeyboardPlatform, /** When this app load started (from Flags), for the notification sinks. */ sessionStartMs: Schema.Number, page: Schema.NullOr(PageSlot), @@ -151,6 +158,9 @@ export const sharedOf = (model: Model): Shared => ({ isMobile: model.isMobile, theme: { ...model.themePreference, resolved: resolvedThemeOf(model) }, soundSettings: model.soundSettings, + keys: appKeymap.resolve(model.keybindingOverrides, model.keyboardPlatform), + keybindingOverrides: model.keybindingOverrides, + keyboardPlatform: model.keyboardPlatform, desktopUpdate: model.desktopUpdate, isOverlayOpen: model.modal.current !== null || model.commandPalette.isOpen, }) diff --git a/apps/web-foldkit/src/app/out-messages.story.test.ts b/apps/web-foldkit/src/app/out-messages.story.test.ts index 0a7082dcf..f69e1ae3f 100644 --- a/apps/web-foldkit/src/app/out-messages.story.test.ts +++ b/apps/web-foldkit/src/app/out-messages.story.test.ts @@ -18,18 +18,20 @@ import { BroadcastActivity, SendPresenceUpdate } from "../platform/presence/upda import { ada, hazelOrg, plainMember, signedIn, urlOf } from "../test/root-fixtures" import * as CommandMenu from "../ui/command-menu" import { DEFAULT_SOUND_SETTINGS } from "../notification-sound" +import { combo } from "~/lib/keybindings/key-combo" import * as Menu from "../ui/menu" import { ApplyTheme, CreateDm, LoadExternal, NavigateInternal, + SaveKeybindings, SaveSoundSettings, SaveThemePreference, SignOut, } from "./command" import { Message } from "./message" -import { dmRequestKey, type Model } from "./model" +import { dmRequestKey, type Model, sharedOf } from "./model" import { Message as HomeMessage } from "../page/home/message" import type { DirectoryMember } from "../page/home/model" @@ -300,6 +302,38 @@ describe("RequestedSoundSettings", () => { }) }) +describe("RequestedKeybindings", () => { + test("the Keybinds page's new key is stored by the root and rebinds the hotkeys", () => { + const overrides = { "inbox.open": combo("Mod+J") } + story( + update, + given(signedIn("/hazel/my-settings/keybinds")), + message( + Message.GotPageMessage({ + message: { + _tag: "MySettingsKeybinds", + message: { _tag: "ClickedRecord", actionId: "inbox.open" }, + }, + }), + ), + message( + Message.GotPageMessage({ + message: { + _tag: "MySettingsKeybinds", + message: { _tag: "PressedCombo", combo: combo("Mod+J") }, + }, + }), + ), + Command.expectHas(SaveKeybindings({ overrides })), + Command.resolveAll([SaveKeybindings, Message.CompletedSaveKeybindings()]), + model((m) => { + expect(m.keybindingOverrides).toEqual(overrides) + expect(sharedOf(m).keys["inbox.open"]).toBe("Mod+J") + }), + ) + }) +}) + describe("hotkeys", () => { test("create channel needs the channel.create permission", () => { story( diff --git a/apps/web-foldkit/src/app/subscription.ts b/apps/web-foldkit/src/app/subscription.ts index 395d3bf90..89acf2d9b 100644 --- a/apps/web-foldkit/src/app/subscription.ts +++ b/apps/web-foldkit/src/app/subscription.ts @@ -17,7 +17,7 @@ import { chatChannelIdOf, isChatRoute, orgSlugOf } from "../route" import type { Member, Organization } from "../session" import * as ShellSubscription from "../shell/subscription" import * as CommandPalette from "../overlay/command-palette" -import { layoutHotkeys } from "../overlay/hotkeys" +import { LayoutKeys, layoutHotkeys, layoutKeysOf } from "../overlay/hotkeys" import * as Modal from "../overlay/modal" import * as Platform from "../platform" import { desktopMenuItems } from "../platform/desktop-menu" @@ -218,6 +218,7 @@ const overlaySubscriptions = Subscription.make()((entry) => ({ isEnabled: Schema.Boolean, isChannelNavigationEnabled: Schema.Boolean, isOverlayOpen: Schema.Boolean, + keys: LayoutKeys, }, { modelToDependencies: (model) => { @@ -226,11 +227,12 @@ const overlaySubscriptions = Subscription.make()((entry) => ({ isEnabled: authOf(model.session) === "SignedIn" && orgSlugOf(model.route) !== undefined, isChannelNavigationEnabled: isChatRoute(model.route) && !isOverlayOpen, isOverlayOpen, + keys: layoutKeysOf(sharedOf(model).keys), } }, - dependenciesToStream: ({ isEnabled, isChannelNavigationEnabled, isOverlayOpen }) => + dependenciesToStream: ({ isEnabled, isChannelNavigationEnabled, isOverlayOpen, keys }) => isEnabled - ? layoutHotkeys((actionId) => Message.PressedHotkey({ actionId }), { + ? layoutHotkeys((actionId) => Message.PressedHotkey({ actionId }), keys, { isChannelNavigationEnabled, isOverlayOpen, }) diff --git a/apps/web-foldkit/src/app/view.ts b/apps/web-foldkit/src/app/view.ts index 6b4136ed7..7ab724a62 100644 --- a/apps/web-foldkit/src/app/view.ts +++ b/apps/web-foldkit/src/app/view.ts @@ -78,6 +78,8 @@ const shellContextOf = (model: Model, data: ShellData): ShellContext => ({ avatarUrl: data.user.avatarUrl, }, appVersion: __APP_VERSION__, + paletteKey: sharedOf(model).keys["commandPalette.open"], + createChannelKey: sharedOf(model).keys["channel.create"], }) /** The routed page, or an empty placeholder until its page is ported. */ diff --git a/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.test.ts b/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.test.ts deleted file mode 100644 index 79260a5e9..000000000 --- a/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { validateHotkey } from "@tanstack/hotkeys" -import { describe, expect, it } from "vitest" -import { HOTKEY_DEFINITIONS, HOTKEY_DEFINITIONS_BY_ID, isAppHotkeyActionId } from "./hotkey-registry" - -describe("hotkey-registry", () => { - it("has unique action ids", () => { - const actionIds = HOTKEY_DEFINITIONS.map((definition) => definition.id) - expect(new Set(actionIds).size).toBe(actionIds.length) - }) - - it("indexes all definitions by id", () => { - expect(Object.keys(HOTKEY_DEFINITIONS_BY_ID).length).toBe(HOTKEY_DEFINITIONS.length) - for (const definition of HOTKEY_DEFINITIONS) { - expect(HOTKEY_DEFINITIONS_BY_ID[definition.id]).toEqual(definition) - } - }) - - it("uses valid default hotkeys", () => { - for (const definition of HOTKEY_DEFINITIONS) { - const validationResult = validateHotkey(definition.defaultHotkey) - expect(validationResult.valid).toBe(true) - } - }) - - it("correctly narrows action ids", () => { - expect(isAppHotkeyActionId("commandPalette.open")).toBe(true) - expect(isAppHotkeyActionId("not-a-real-hotkey-action")).toBe(false) - }) -}) diff --git a/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.ts b/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.ts deleted file mode 100644 index 870fb74a4..000000000 --- a/apps/web-foldkit/src/core/lib/hotkeys/hotkey-registry.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { Hotkey } from "@tanstack/hotkeys" - -export type AppHotkeyCategory = "global" | "navigation" | "modal" - -export interface AppHotkeyDefinition { - id: string - category: AppHotkeyCategory - label: string - description: string - defaultHotkey: Hotkey -} - -export const HOTKEY_DEFINITIONS = [ - { - id: "commandPalette.open", - category: "global", - label: "Open Command Palette", - description: "Open the command palette home screen.", - defaultHotkey: "Mod+K", - }, - { - id: "search.open", - category: "navigation", - label: "Search Messages", - description: "Open command palette directly in message search.", - defaultHotkey: "Mod+Shift+F", - }, - { - id: "channel.create", - category: "navigation", - label: "Create Channel", - description: "Open the create channel modal.", - defaultHotkey: "Mod+Alt+N", - }, - { - id: "dm.create", - category: "navigation", - label: "Create DM", - description: "Open the create direct message modal.", - defaultHotkey: "Mod+Alt+D", - }, - { - id: "invite.email", - category: "navigation", - label: "Invite People", - description: "Open the invite people modal.", - defaultHotkey: "Mod+Alt+I", - }, - { - id: "channel.previous", - category: "navigation", - label: "Previous Channel", - description: "Go to the channel above in the sidebar.", - defaultHotkey: "Alt+ArrowUp", - }, - { - id: "channel.next", - category: "navigation", - label: "Next Channel", - description: "Go to the channel below in the sidebar.", - defaultHotkey: "Alt+ArrowDown", - }, - { - id: "channel.previousUnread", - category: "navigation", - label: "Previous Unread Channel", - description: "Go to the nearest unread channel above in the sidebar.", - defaultHotkey: "Alt+Shift+ArrowUp", - }, - { - id: "channel.nextUnread", - category: "navigation", - label: "Next Unread Channel", - description: "Go to the nearest unread channel below in the sidebar.", - defaultHotkey: "Alt+Shift+ArrowDown", - }, - { - id: "mention.previous", - category: "navigation", - label: "Previous Unread Mention", - description: "Go to the nearest channel above with an unread mention.", - defaultHotkey: "Mod+Alt+Shift+ArrowUp", - }, - { - id: "mention.next", - category: "navigation", - label: "Next Unread Mention", - description: "Go to the nearest channel below with an unread mention.", - defaultHotkey: "Mod+Alt+Shift+ArrowDown", - }, - { - id: "organization.previous", - category: "navigation", - label: "Previous Organization", - description: "Switch to the organization above in the organization switcher.", - defaultHotkey: "Mod+Alt+ArrowUp", - }, - { - id: "organization.next", - category: "navigation", - label: "Next Organization", - description: "Switch to the organization below in the organization switcher.", - defaultHotkey: "Mod+Alt+ArrowDown", - }, - { - id: "organization.markRead", - category: "global", - label: "Mark Organization Read", - description: "Mark every channel in this organization as read.", - defaultHotkey: "Shift+Escape", - }, - { - id: "inbox.open", - category: "navigation", - label: "Open Inbox", - description: "Open your notifications.", - defaultHotkey: "Mod+I", - }, - { - id: "shortcuts.open", - category: "global", - label: "Keyboard Shortcuts", - description: "Show every keyboard shortcut.", - defaultHotkey: "Mod+/", - }, - { - id: "sidebar.toggle", - category: "global", - label: "Toggle Sidebar", - description: "Toggle the left sidebar visibility.", - // Slack's; Mod+B is bold in the composer. - defaultHotkey: "Mod+Shift+D", - }, - { - id: "history.back", - category: "navigation", - label: "Go Back", - description: "Go back to the previous page.", - defaultHotkey: "Mod+[", - }, - { - id: "history.forward", - category: "navigation", - label: "Go Forward", - description: "Go forward to the next page.", - defaultHotkey: "Mod+]", - }, - { - id: "imageViewer.close", - category: "modal", - label: "Close Image Viewer", - description: "Close the image viewer modal.", - defaultHotkey: "Escape", - }, - { - id: "imageViewer.prev", - category: "modal", - label: "Previous Image", - description: "Show the previous image in the viewer.", - defaultHotkey: "ArrowLeft", - }, - { - id: "imageViewer.next", - category: "modal", - label: "Next Image", - description: "Show the next image in the viewer.", - defaultHotkey: "ArrowRight", - }, -] as const satisfies ReadonlyArray - -export type AppHotkeyActionId = (typeof HOTKEY_DEFINITIONS)[number]["id"] - -export const HOTKEY_DEFINITIONS_BY_ID = HOTKEY_DEFINITIONS.reduce( - (accumulator, definition) => { - accumulator[definition.id as AppHotkeyActionId] = definition - return accumulator - }, - {} as Record, -) - -export function isAppHotkeyActionId(value: string): value is AppHotkeyActionId { - return Object.hasOwn(HOTKEY_DEFINITIONS_BY_ID, value) -} diff --git a/apps/web-foldkit/src/core/lib/hotkeys/shortcut-catalog.ts b/apps/web-foldkit/src/core/lib/hotkeys/shortcut-catalog.ts deleted file mode 100644 index e276d06f1..000000000 --- a/apps/web-foldkit/src/core/lib/hotkeys/shortcut-catalog.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { HOTKEY_DEFINITIONS_BY_ID } from "./hotkey-registry" - -/** - * Every keyboard shortcut, grouped as the Mod+/ dialog lists them: the app's rebindable hotkeys - * (`hotkey-registry.ts`) and the fixed keys of the composer, the messages and the channel page. - */ - -export interface Shortcut { - readonly label: string - /** One or more key combos, in `@tanstack/hotkeys` notation (`Mod+Shift+K`). */ - readonly keys: ReadonlyArray -} - -export interface ShortcutSection { - readonly title: string - readonly shortcuts: ReadonlyArray -} - -const fromRegistry = (id: keyof typeof HOTKEY_DEFINITIONS_BY_ID): Shortcut => ({ - label: HOTKEY_DEFINITIONS_BY_ID[id].label, - keys: [HOTKEY_DEFINITIONS_BY_ID[id].defaultHotkey], -}) - -export const SHORTCUT_SECTIONS: ReadonlyArray = [ - { - title: "Navigation", - shortcuts: [ - fromRegistry("commandPalette.open"), - fromRegistry("search.open"), - fromRegistry("channel.previous"), - fromRegistry("channel.next"), - fromRegistry("channel.previousUnread"), - fromRegistry("channel.nextUnread"), - fromRegistry("organization.previous"), - fromRegistry("organization.next"), - fromRegistry("inbox.open"), - fromRegistry("sidebar.toggle"), - fromRegistry("history.back"), - fromRegistry("history.forward"), - ], - }, - { - title: "Channel", - shortcuts: [ - { label: "Mark Channel Read", keys: ["Escape"] }, - fromRegistry("organization.markRead"), - { label: "Scroll Messages", keys: ["PageUp", "PageDown"] }, - { label: "Emoji Picker", keys: ["Mod+E"] }, - { label: "GIF Picker", keys: ["Mod+G"] }, - { label: "Upload a File", keys: ["Mod+Shift+U"] }, - { label: "Pinned Messages", keys: ["Mod+P"] }, - fromRegistry("channel.create"), - fromRegistry("dm.create"), - fromRegistry("invite.email"), - ], - }, - { - title: "Composer", - shortcuts: [ - { label: "Send", keys: ["Enter"] }, - { label: "New Line", keys: ["Shift+Enter"] }, - { label: "Edit Your Last Message", keys: ["ArrowUp"] }, - { label: "Cancel Edit or Reply", keys: ["Escape"] }, - { label: "Bold, Italic, Underline", keys: ["Mod+B", "Mod+I", "Mod+U"] }, - { label: "Strikethrough", keys: ["Mod+Shift+X"] }, - { label: "Go to the Messages", keys: ["Shift+Tab"] }, - ], - }, - { - title: "Focused Message", - shortcuts: [ - { label: "Previous / Next Message", keys: ["ArrowUp", "ArrowDown"] }, - { label: "Edit", keys: ["E"] }, - { label: "Reply", keys: ["R"] }, - { label: "Start a Thread", keys: ["T"] }, - { label: "Pin or Unpin", keys: ["P"] }, - { label: "Add a Reaction", keys: ["+"] }, - { label: "Copy Text", keys: ["Mod+C"] }, - { label: "Delete", keys: ["Delete"] }, - { label: "Delete Without Confirming", keys: ["Shift+Delete"] }, - { label: "Back to the Composer", keys: ["Escape"] }, - ], - }, - { title: "Help", shortcuts: [fromRegistry("shortcuts.open")] }, -] diff --git a/apps/web-foldkit/src/core/lib/keybindings/app-keymap.ts b/apps/web-foldkit/src/core/lib/keybindings/app-keymap.ts new file mode 100644 index 000000000..9eccf857d --- /dev/null +++ b/apps/web-foldkit/src/core/lib/keybindings/app-keymap.ts @@ -0,0 +1,325 @@ +import { defineKeymap } from "./keymap" + +/** + * Every keyboard shortcut in the app. The org layout (`overlay/hotkeys.ts`) and the channel page + * (`page/chat/channel/hotkeys.ts`) bind the rebindable ones from the user's keys; the composer's and + * the focused message's keys are fixed (the editor and the message list handle them) and are listed + * here so the shortcut dialog shows them and a rebinding can warn when it shadows one. + */ +export const KEYMAP_CATEGORIES = ["Navigation", "Actions", "Channel", "Composer", "Message", "Help"] as const +export type KeymapCategory = (typeof KEYMAP_CATEGORIES)[number] + +const navigation = "Navigation" satisfies KeymapCategory +const actions = "Actions" satisfies KeymapCategory +const channel = "Channel" satisfies KeymapCategory +const composer = "Composer" satisfies KeymapCategory +const message = "Message" satisfies KeymapCategory +const help = "Help" satisfies KeymapCategory + +export const appKeymap = defineKeymap({ + // Navigation + "commandPalette.open": { + label: "Open Command Palette", + description: "Jump to a channel, person or command.", + category: navigation, + defaultKey: "Mod+K", + }, + "search.open": { + label: "Search Messages", + description: "Open the command palette in message search.", + category: navigation, + defaultKey: "Mod+Shift+F", + }, + "channel.previous": { + label: "Previous Channel", + description: "Go to the channel above in the sidebar.", + category: navigation, + defaultKey: "Alt+ArrowUp", + }, + "channel.next": { + label: "Next Channel", + description: "Go to the channel below in the sidebar.", + category: navigation, + defaultKey: "Alt+ArrowDown", + }, + "channel.previousUnread": { + label: "Previous Unread Channel", + description: "Go to the nearest unread channel above.", + category: navigation, + defaultKey: "Alt+Shift+ArrowUp", + }, + "channel.nextUnread": { + label: "Next Unread Channel", + description: "Go to the nearest unread channel below.", + category: navigation, + defaultKey: "Alt+Shift+ArrowDown", + }, + "mention.previous": { + label: "Previous Unread Mention", + description: "Go to the nearest channel above with an unread mention.", + category: navigation, + defaultKey: "Mod+Alt+Shift+ArrowUp", + }, + "mention.next": { + label: "Next Unread Mention", + description: "Go to the nearest channel below with an unread mention.", + category: navigation, + defaultKey: "Mod+Alt+Shift+ArrowDown", + }, + "organization.previous": { + label: "Previous Organization", + description: "Switch to the organization above.", + category: navigation, + defaultKey: "Mod+Alt+ArrowUp", + }, + "organization.next": { + label: "Next Organization", + description: "Switch to the organization below.", + category: navigation, + defaultKey: "Mod+Alt+ArrowDown", + }, + "inbox.open": { + label: "Open Inbox", + description: "Open your notifications.", + category: navigation, + defaultKey: "Mod+I", + }, + "sidebar.toggle": { + label: "Toggle Sidebar", + description: "Show or hide the left sidebar.", + category: navigation, + // Slack's; Mod+B is bold in the composer. + defaultKey: "Mod+Shift+D", + }, + "history.back": { + label: "Go Back", + description: "Go back to the previous page.", + category: navigation, + defaultKey: "Mod+[", + }, + "history.forward": { + label: "Go Forward", + description: "Go forward to the next page.", + category: navigation, + defaultKey: "Mod+]", + }, + + // Actions + "channel.create": { + label: "Create Channel", + description: "Open the create channel dialog.", + category: actions, + defaultKey: "Mod+Alt+N", + }, + "dm.create": { + label: "Create DM", + description: "Start a direct message.", + category: actions, + defaultKey: "Mod+Alt+D", + }, + "invite.email": { + label: "Invite People", + description: "Invite people to this organization.", + category: actions, + defaultKey: "Mod+Alt+I", + }, + "organization.markRead": { + label: "Mark Organization Read", + description: "Mark every channel in this organization as read.", + category: actions, + defaultKey: "Shift+Escape", + }, + + // Channel + "channel.markRead": { + label: "Mark Channel Read", + description: "Mark the open channel as read.", + category: channel, + defaultKey: "Escape", + }, + "channel.emojiPicker": { + label: "Emoji Picker", + description: "Open the emoji picker in the composer.", + category: channel, + defaultKey: "Mod+E", + }, + "channel.gifPicker": { + label: "GIF Picker", + description: "Open the GIF picker in the composer.", + category: channel, + defaultKey: "Mod+G", + }, + "channel.upload": { + label: "Upload a File", + description: "Choose files to attach.", + category: channel, + defaultKey: "Mod+Shift+U", + }, + "channel.pins": { + label: "Pinned Messages", + description: "Show the channel's pinned messages.", + category: channel, + defaultKey: "Mod+P", + }, + "channel.scrollUp": { + label: "Scroll Up", + description: "Scroll the messages up a page.", + category: channel, + defaultKey: "PageUp", + }, + "channel.scrollDown": { + label: "Scroll Down", + description: "Scroll the messages down a page.", + category: channel, + defaultKey: "PageDown", + }, + + // Composer (fixed: the editor's own keys) + "composer.send": { + label: "Send", + description: "Send the message.", + category: composer, + defaultKey: "Enter", + rebindable: false, + }, + "composer.newLine": { + label: "New Line", + description: "Start a new line.", + category: composer, + defaultKey: "Shift+Enter", + rebindable: false, + }, + "composer.editLast": { + label: "Edit Your Last Message", + description: "From an empty composer.", + category: composer, + defaultKey: "ArrowUp", + rebindable: false, + }, + "composer.cancel": { + label: "Cancel Edit or Reply", + description: "Leave the edit or reply.", + category: composer, + defaultKey: "Escape", + rebindable: false, + }, + "composer.bold": { + label: "Bold", + description: "Bold the selection.", + category: composer, + defaultKey: "Mod+B", + rebindable: false, + }, + "composer.italic": { + label: "Italic", + description: "Italicize the selection.", + category: composer, + defaultKey: "Mod+I", + rebindable: false, + }, + "composer.underline": { + label: "Underline", + description: "Underline the selection.", + category: composer, + defaultKey: "Mod+U", + rebindable: false, + }, + "composer.strikethrough": { + label: "Strikethrough", + description: "Strike through the selection.", + category: composer, + defaultKey: "Mod+Shift+X", + rebindable: false, + }, + "composer.toMessages": { + label: "Go to the Messages", + description: "Move focus to the last message.", + category: composer, + defaultKey: "Shift+Tab", + rebindable: false, + }, + + // Focused message (fixed: the message list's keys) + "message.move": { + label: "Previous / Next Message", + description: "Move between messages.", + category: message, + defaultKey: "ArrowUp", + alsoKeys: ["ArrowDown"], + rebindable: false, + }, + "message.edit": { + label: "Edit", + description: "Edit your message.", + category: message, + defaultKey: "E", + rebindable: false, + }, + "message.reply": { + label: "Reply", + description: "Reply to the message.", + category: message, + defaultKey: "R", + rebindable: false, + }, + "message.thread": { + label: "Start a Thread", + description: "Open a thread on the message.", + category: message, + defaultKey: "T", + rebindable: false, + }, + "message.pin": { + label: "Pin or Unpin", + description: "Pin the message to the channel.", + category: message, + defaultKey: "P", + rebindable: false, + }, + "message.react": { + label: "Add a Reaction", + description: "React to the message.", + category: message, + defaultKey: "Plus", + rebindable: false, + }, + "message.copy": { + label: "Copy Text", + description: "Copy the message's text.", + category: message, + defaultKey: "Mod+C", + rebindable: false, + }, + "message.delete": { + label: "Delete", + description: "Delete the message.", + category: message, + defaultKey: "Delete", + rebindable: false, + }, + "message.deleteNow": { + label: "Delete Without Confirming", + description: "Delete the message right away.", + category: message, + defaultKey: "Shift+Delete", + rebindable: false, + }, + "message.toComposer": { + label: "Back to the Composer", + description: "Return focus to the composer.", + category: message, + defaultKey: "Escape", + rebindable: false, + }, + + // Help + "shortcuts.open": { + label: "Keyboard Shortcuts", + description: "Show every keyboard shortcut.", + category: help, + defaultKey: "Mod+/", + }, +}) + +export type AppActionId = typeof appKeymap.ActionId.Type +export type AppKeys = ReturnType diff --git a/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts b/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts new file mode 100644 index 000000000..91684f678 --- /dev/null +++ b/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts @@ -0,0 +1,86 @@ +import { Option, Schema } from "effect" +import { describe, expect, test } from "vitest" +import { combo, comboLabel, comboOfPress, identityOf, KeyCombo, keyCaps, normalizeCombo } from "./key-combo" + +const press = ( + key: string, + modifiers: Partial> = {}, +) => ({ + key, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + ...modifiers, +}) + +describe("KeyCombo", () => { + test.each([ + "Mod+K", + "Mod+Shift+F", + "Alt+ArrowUp", + "Escape", + "Mod+/", + "Shift+Enter", + "Plus", + "Mod+Alt+Shift+ArrowDown", + ])("accepts the canonical %s", (text) => expect(Schema.is(KeyCombo)(text)).toBe(true)) + + test.each([ + ["", "empty"], + ["Mod", "a modifier alone"], + ["Shift+Shift+K", "a repeated modifier"], + ["Mod+Meta+K", "Mod with Meta"], + ["Cmd+K", "an unknown modifier"], + ["Mod+k", "a lower case letter (not canonical)"], + ["Shift+Mod+K", "modifiers out of order"], + ["Mod+Banana", "an unknown named key"], + ])("rejects %j: %s", (text) => expect(Schema.is(KeyCombo)(text)).toBe(false)) + + test("normalizeCombo makes any spelling canonical", () => { + expect(normalizeCombo("shift+mod+k")).toEqual(Option.some("Mod+Shift+K")) + expect(normalizeCombo("escape")).toEqual(Option.some("Escape")) + expect(normalizeCombo(" + ")).toEqual(Option.none()) + }) + + test("combo throws on a typo", () => expect(() => combo("Mod+")).toThrow(/Invalid key combo/)) +}) + +describe("comboOfPress", () => { + test("the platform's primary key is Mod, so a combo recorded on a Mac works on Windows", () => { + expect(comboOfPress(press("k", { metaKey: true }), "mac")).toEqual(Option.some("Mod+K")) + expect(comboOfPress(press("k", { ctrlKey: true }), "windows")).toEqual(Option.some("Mod+K")) + expect(comboOfPress(press("k", { ctrlKey: true }), "mac")).toEqual(Option.some("Control+K")) + expect(comboOfPress(press("k", { metaKey: true }), "windows")).toEqual(Option.some("Meta+K")) + }) + + test("a modifier alone, or a key no binding can match, makes no combo", () => { + expect(comboOfPress(press("Shift", { shiftKey: true }), "mac")).toEqual(Option.none()) + expect(comboOfPress(press("Meta", { metaKey: true }), "mac")).toEqual(Option.none()) + expect(comboOfPress(press("Dead", { altKey: true }), "mac")).toEqual(Option.none()) + }) + + test("space and plus get their names, other keys their canonical case", () => { + expect(comboOfPress(press(" ", { ctrlKey: true }), "linux")).toEqual(Option.some("Mod+Space")) + expect(comboOfPress(press("+"), "linux")).toEqual(Option.some("Plus")) + expect(comboOfPress(press("arrowup", { altKey: true }), "linux")).toEqual(Option.some("Alt+ArrowUp")) + expect(comboOfPress(press("?", { shiftKey: true }), "linux")).toEqual(Option.some("Shift+?")) + }) +}) + +describe("identityOf", () => { + test("Mod is the platform's key", () => { + expect(identityOf(combo("Mod+K"), "mac")).toBe(identityOf(combo("Meta+K"), "mac")) + expect(identityOf(combo("Mod+K"), "windows")).toBe(identityOf(combo("Control+K"), "windows")) + expect(identityOf(combo("Mod+K"), "mac")).not.toBe(identityOf(combo("Control+K"), "mac")) + }) +}) + +describe("labels", () => { + test("Mac symbols in Apple's order; Ctrl-style elsewhere", () => { + expect(keyCaps("Mod+Shift+K", "mac")).toEqual(["⇧", "⌘", "K"]) + expect(comboLabel("Mod+Alt+ArrowUp", "mac")).toBe("⌥⌘↑") + expect(comboLabel("Mod+Shift+K", "windows")).toBe("Ctrl+Shift+K") + expect(comboLabel("Escape", "linux")).toBe("Esc") + }) +}) diff --git a/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts b/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts new file mode 100644 index 000000000..c9451d95a --- /dev/null +++ b/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts @@ -0,0 +1,220 @@ +import { Option, Schema } from "effect" + +/** + * One key press with its modifiers, in the notation Foldkit's `Dom.streamFromKeyBindings` matches + * (`Mod+Shift+K`, `Alt+ArrowUp`, `Escape`). `Mod` is ⌘ on Apple platforms and Ctrl elsewhere, so a + * combo recorded on a Mac means the same thing on Windows. + * + * Combos are kept canonical (modifiers in `MODIFIER_ORDER`, letters upper case), so two spellings of + * one press are equal strings, and `identityOf` folds `Mod` into the platform's key for conflicts. + */ + +export const Platform = Schema.Literals(["mac", "windows", "linux"]) +export type Platform = typeof Platform.Type + +export const detectPlatform = (): Platform => { + if (typeof navigator === "undefined") return "linux" + const platform = navigator.platform?.toLowerCase() ?? "" + const userAgent = navigator.userAgent?.toLowerCase() ?? "" + if (/mac|iphone|ipad|ipod/.test(platform) || /mac|iphone|ipad|ipod/.test(userAgent)) return "mac" + if (platform.includes("win") || userAgent.includes("win")) return "windows" + return "linux" +} + +export const MODIFIER_ORDER = ["Mod", "Control", "Meta", "Alt", "Shift"] as const +export type Modifier = (typeof MODIFIER_ORDER)[number] + +export interface ParsedCombo { + readonly modifiers: ReadonlySet + /** The key, canonical: `K`, `/`, `ArrowUp`, `Space`, `Plus`. */ + readonly key: string +} + +/** `KeyboardEvent.key` values that are only modifiers, or that no binding can match. */ +const UNBINDABLE_KEYS = new Set([ + "alt", + "altgraph", + "capslock", + "control", + "fn", + "fnlock", + "hyper", + "meta", + "numlock", + "os", + "scrolllock", + "shift", + "super", + "symbol", + "symbollock", + "dead", + "unidentified", + "process", + "compose", +]) + +const MODIFIER_ALIASES: Readonly> = { + mod: "Mod", + control: "Control", + meta: "Meta", + alt: "Alt", + shift: "Shift", +} + +/** Named keys, keyed by their lower case: the canonical spelling `KeyboardEvent.key` uses. */ +const NAMED_KEYS: Readonly> = Object.fromEntries( + [ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Escape", + "Enter", + "Tab", + "Backspace", + "Delete", + "Insert", + "Home", + "End", + "PageUp", + "PageDown", + "Space", + "Plus", + ...Array.from({ length: 24 }, (_, index) => `F${index + 1}`), + ].map((key) => [key.toLowerCase(), key]), +) + +const canonicalKey = (raw: string): string | undefined => { + if (raw === " ") return "Space" + if (raw === "+") return "Plus" + const lower = raw.toLowerCase() + if (lower === "" || UNBINDABLE_KEYS.has(lower)) return undefined + if (NAMED_KEYS[lower] !== undefined) return NAMED_KEYS[lower] + // One printable character (a letter, digit or symbol); longer names are keys we do not know. + return [...raw].length === 1 ? raw.toUpperCase() : undefined +} + +/** Reads `Mod+Shift+K`; `undefined` for anything Foldkit would reject. */ +export const parseCombo = (text: string): ParsedCombo | undefined => { + const parts = text.split("+") + const rawKey = parts.at(-1) + if (rawKey === undefined) return undefined + const key = canonicalKey(rawKey.trim()) + if (key === undefined) return undefined + const modifiers = new Set() + for (const part of parts.slice(0, -1)) { + const modifier = MODIFIER_ALIASES[part.trim().toLowerCase()] + if (modifier === undefined || modifiers.has(modifier)) return undefined + modifiers.add(modifier) + } + if (modifiers.has("Mod") && (modifiers.has("Control") || modifiers.has("Meta"))) return undefined + return { modifiers, key } +} + +const formatParsed = ({ modifiers, key }: ParsedCombo): string => + [...MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)), key].join("+") + +/** A key press, as stored and as bound: valid for Foldkit, and canonical. */ +export const KeyCombo = Schema.String.check( + Schema.makeFilter((text: string) => { + const parsed = parseCombo(text) + if (parsed === undefined) return "not a key combo, like Mod+Shift+K" + return formatParsed(parsed) === text || `not canonical: write it as ${formatParsed(parsed)}` + }), +).pipe(Schema.brand("KeyCombo")) +export type KeyCombo = typeof KeyCombo.Type + +/** Any spelling of a combo, made canonical (`ctrl+shift+k` is not one: use `Control`). */ +export const normalizeCombo = (text: string): Option.Option => + Option.map(Option.fromUndefinedOr(parseCombo(text)), (parsed) => formatParsed(parsed) as KeyCombo) + +/** A combo written in code: canonicalizes it, and throws on a typo (caught by the keymap's tests). */ +export const combo = (text: string): KeyCombo => + Option.getOrThrowWith(normalizeCombo(text), () => new Error(`Invalid key combo "${text}"`)) + +/** The physical press a combo means on `platform`: `Mod+K` and `Meta+K` are one press on a Mac. */ +export const identityOf = (keyCombo: KeyCombo, platform: Platform): string => { + const parsed = parseCombo(keyCombo) + if (parsed === undefined) return keyCombo + const modifiers = new Set(parsed.modifiers) + if (modifiers.delete("Mod")) modifiers.add(platform === "mac" ? "Meta" : "Control") + return formatParsed({ modifiers, key: parsed.key }).toLowerCase() +} + +/** The fields of a `KeyboardEvent` a recording reads. */ +export interface KeyPress { + readonly key: string + readonly metaKey: boolean + readonly ctrlKey: boolean + readonly altKey: boolean + readonly shiftKey: boolean +} + +/** + * The modifiers held in `press`, with the platform's primary key (⌘ on a Mac, Ctrl elsewhere) as + * `Mod`, so what a Mac user records works on Windows. + */ +export const heldModifiers = (press: KeyPress, platform: Platform): ReadonlySet => { + const held = new Set() + const primary = platform === "mac" ? press.metaKey : press.ctrlKey + const secondary = platform === "mac" ? press.ctrlKey : press.metaKey + if (primary) held.add("Mod") + if (secondary) held.add(platform === "mac" ? "Control" : "Meta") + if (press.altKey) held.add("Alt") + if (press.shiftKey) held.add("Shift") + return held +} + +/** The combo a key press makes; `None` for a press of a modifier alone, or of a key no binding can match. */ +export const comboOfPress = (press: KeyPress, platform: Platform): Option.Option => { + const key = canonicalKey(press.key) + if (key === undefined) return Option.none() + return Option.some(formatParsed({ modifiers: heldModifiers(press, platform), key }) as KeyCombo) +} + +// LABELS + +const MAC_SYMBOLS: Record = { Mod: "⌘", Control: "⌃", Meta: "⌘", Alt: "⌥", Shift: "⇧" } +const STANDARD_LABELS: Record = { + Mod: "Ctrl", + Control: "Ctrl", + Meta: "Win", + Alt: "Alt", + Shift: "Shift", +} +/** Display order: the platform's own (⌃⌥⇧⌘ on a Mac, Ctrl+Alt+Shift elsewhere). */ +const MAC_DISPLAY_ORDER: ReadonlyArray = ["Control", "Alt", "Shift", "Mod", "Meta"] +const STANDARD_DISPLAY_ORDER: ReadonlyArray = ["Mod", "Control", "Alt", "Shift", "Meta"] + +const KEY_LABELS: Readonly> = { + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + Escape: "Esc", + PageUp: "PgUp", + PageDown: "PgDn", + Backspace: "⌫", + Delete: "Del", + Plus: "+", +} + +const modifierLabel = (modifier: Modifier, platform: Platform) => + platform === "mac" ? MAC_SYMBOLS[modifier] : STANDARD_LABELS[modifier] + +/** The modifiers' key caps, in the platform's order. */ +export const modifierCaps = (modifiers: ReadonlySet, platform: Platform): ReadonlyArray => + (platform === "mac" ? MAC_DISPLAY_ORDER : STANDARD_DISPLAY_ORDER) + .filter((modifier) => modifiers.has(modifier)) + .map((modifier) => modifierLabel(modifier, platform)) + +/** One label per key cap: `["⌘", "⇧", "K"]`, or `["Ctrl", "Shift", "K"]`. */ +export const keyCaps = (text: string, platform: Platform = detectPlatform()): ReadonlyArray => { + const parsed = parseCombo(text) + if (parsed === undefined) return [text] + return [...modifierCaps(parsed.modifiers, platform), KEY_LABELS[parsed.key] ?? parsed.key] +} + +/** The whole combo as one label: `⌘⇧K` on a Mac, `Ctrl+Shift+K` elsewhere. */ +export const comboLabel = (text: string, platform: Platform = detectPlatform()): string => + keyCaps(text, platform).join(platform === "mac" ? "" : "+") diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts new file mode 100644 index 000000000..501449f96 --- /dev/null +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest" +import { appKeymap } from "./app-keymap" +import { combo, identityOf, type Platform } from "./key-combo" +import { defineKeymap } from "./keymap" + +const keymap = defineKeymap({ + open: { label: "Open", description: "", category: "A", defaultKey: "Mod+K" }, + search: { label: "Search", description: "", category: "A", defaultKey: "Mod+Shift+F" }, + inbox: { label: "Inbox", description: "", category: "A", defaultKey: "Mod+I" }, + bold: { label: "Bold", description: "", category: "B", defaultKey: "Mod+B", rebindable: false }, +}) + +describe("defineKeymap", () => { + test("with no overrides every action has its default", () => { + expect(keymap.resolve({}, "mac")).toEqual({ + open: "Mod+K", + search: "Mod+Shift+F", + inbox: "Mod+I", + bold: "Mod+B", + }) + }) + + test("an override replaces the default; null unbinds", () => { + expect(keymap.resolve({ open: combo("Mod+P"), inbox: null }, "mac")).toMatchObject({ + open: "Mod+P", + inbox: null, + }) + }) + + test("a default an override took is dropped, so no two actions share a key", () => { + // Meta+K is Mod+K on a Mac, but not on Windows. + expect(keymap.resolve({ search: combo("Meta+K") }, "mac").open).toBeNull() + expect(keymap.resolve({ search: combo("Meta+K") }, "windows").open).toBe("Mod+K") + }) + + test("assign takes the key from the action that had it", () => { + const { overrides, displaced, shadows } = keymap.assign({}, "search", combo("Mod+K"), "linux") + expect(displaced).toEqual(["open"]) + expect(shadows).toEqual([]) + expect(keymap.resolve(overrides, "linux")).toMatchObject({ search: "Mod+K", open: null }) + }) + + test("assign reports a fixed key it shadows", () => { + expect(keymap.assign({}, "inbox", combo("Mod+B"), "linux").shadows).toEqual(["bold"]) + }) + + test("assigning an action's own default clears its override", () => { + const moved = keymap.assign({}, "open", combo("Mod+P"), "linux").overrides + expect(keymap.assign(moved, "open", combo("Mod+K"), "linux").overrides).toEqual({}) + }) + + test("reset and unbind", () => { + const overrides = { open: combo("Mod+P") } + expect(keymap.reset(overrides, "open")).toEqual({}) + expect(keymap.unbind({}, "open")).toEqual({ open: null }) + }) + + test("clean drops unknown actions, fixed actions and keys that no longer parse", () => { + expect( + keymap.clean({ open: "Mod+P", removed: "Mod+J", bold: "Mod+J", search: "Mod+", inbox: null }), + ).toEqual({ open: "Mod+P", inbox: null }) + }) + + test("bindings skip unbound actions", () => { + const keys = keymap.resolve({ inbox: null }, "linux") + const bindings = keymap.bindings(keys, ["open", "inbox"], (id) => ({ mapEvent: () => id })) + expect(bindings.map((binding) => binding.keys)).toEqual(["Mod+K"]) + }) +}) + +describe("appKeymap", () => { + test.each(["mac", "windows", "linux"])( + "no two rebindable defaults share a key on %s", + (platform) => { + const seen = new Map() + for (const action of appKeymap.actions) { + if (!action.rebindable || action.defaultKey === null) continue + const identity = identityOf(action.defaultKey, platform) + expect(seen.get(identity), `${action.id} and ${seen.get(identity)}`).toBeUndefined() + seen.set(identity, action.id) + } + }, + ) + + test("with no overrides every action keeps its default", () => { + expect(appKeymap.resolve({}, "mac")).toEqual(appKeymap.defaults) + }) +}) diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts new file mode 100644 index 000000000..d5e508e79 --- /dev/null +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts @@ -0,0 +1,203 @@ +import { Option, Schema } from "effect" +import type { Dom } from "foldkit" +import { combo, identityOf, KeyCombo, type Platform } from "./key-combo" + +/** + * A keymap: every action a key can trigger, declared once, with its default key, where it shows in + * the shortcut list and the Keybinds settings, and whether the user may rebind it. + * + * ```ts + * const keymap = defineKeymap({ + * "inbox.open": { label: "Open Inbox", description: "…", category: "Navigation", defaultKey: "Mod+I" }, + * "composer.send": { label: "Send", description: "…", category: "Composer", defaultKey: "Enter", rebindable: false }, + * }) + * keymap.ActionId // Schema.Literals of the ids + * keymap.resolve(overrides) // the key each action has now (`null`: unbound) + * keymap.bindings(keys, ids, …) // Foldkit `KeyBinding`s for the ids one subscription owns + * ``` + * + * The user's choices are `Overrides`: an action's own key, or `null` to unbind it. They are stored as + * loose JSON and cleaned on load, so an action that was renamed or removed, or a key that no longer + * parses, falls back to the default instead of losing every other override. + */ + +export interface ActionSpec { + readonly label: string + readonly description: string + readonly category: Category + /** The default key, in any spelling `combo` accepts; `null` for an action with no default. */ + readonly defaultKey: string | null + /** Rebindable in settings (default `true`). Fixed keys are listed, and reserved against conflicts. */ + readonly rebindable?: boolean + /** More keys that do the same; listed, never rebindable (`PageUp` / `PageDown`). */ + readonly alsoKeys?: ReadonlyArray +} + +export interface Action { + readonly id: Id + readonly label: string + readonly description: string + readonly category: Category + readonly defaultKey: KeyCombo | null + readonly rebindable: boolean + readonly alsoKeys: ReadonlyArray +} + +/** The user's changes: an action's key, or `null` for "unbound". Missing actions keep their default. */ +export const Overrides = Schema.Record(Schema.String, Schema.NullOr(KeyCombo)) +export type Overrides = typeof Overrides.Type + +/** What storage holds: the same shape, unchecked, so one bad entry cannot void the rest. */ +export const StoredOverrides = Schema.Record(Schema.String, Schema.NullOr(Schema.String)) + +/** The key each action has now. */ +export type Keys = Readonly> + +/** An assignment that took a key away from other actions. */ +export interface Assignment { + readonly overrides: Overrides + /** Rebindable actions that had this key and are now unbound. */ + readonly displaced: ReadonlyArray + /** Fixed keys that share it: the new binding may not fire where they apply (the composer's Mod+B). */ + readonly shadows: ReadonlyArray +} + +/** How a subscription binds one of its actions; the keys come from the keymap. */ +export interface BindingOptions { + readonly mapEvent: (event: KeyboardEvent) => Output + readonly isEnabled?: boolean + readonly whileTyping?: Dom.WhileTyping + readonly preventDefault?: boolean +} + +const decodeCombo = Schema.decodeUnknownOption(KeyCombo) + +export const defineKeymap = >>(specs: Specs) => { + type Id = Extract + type Category = Specs[Id]["category"] + + const ids = Object.keys(specs) as Array + const actions: ReadonlyArray> = ids.map((id) => { + const spec = specs[id]! + return { + id, + label: spec.label, + description: spec.description, + category: spec.category as Category, + defaultKey: spec.defaultKey === null ? null : combo(spec.defaultKey), + rebindable: spec.rebindable ?? true, + alsoKeys: (spec.alsoKeys ?? []).map(combo), + } + }) + const byId = Object.fromEntries(actions.map((action) => [action.id, action])) as Readonly< + Record> + > + const isActionId = (value: string): value is Id => Object.hasOwn(byId, value) + const ActionId = Schema.Literals(ids as unknown as readonly [Id, ...Array]) + + const defaults = Object.fromEntries(actions.map((action) => [action.id, action.defaultKey])) as Keys + + /** Drops overrides for unknown actions, fixed actions, and keys that no longer parse. */ + const clean = (stored: Readonly>): Overrides => { + const cleaned: Record = {} + for (const [id, value] of Object.entries(stored)) { + if (!isActionId(id) || !byId[id].rebindable) continue + if (value === null) cleaned[id] = null + else Option.map(decodeCombo(value), (key) => (cleaned[id] = key)) + } + return cleaned + } + + const isOverridden = (overrides: Overrides, id: Id): boolean => Object.hasOwn(overrides, id) + + /** + * The key of every action: its override, else its default. A default that an overridden action + * took is dropped, so no two rebindable actions ever share a key (Foldkit rejects a table with + * duplicates, and both actions would fire). + */ + const resolve = (overrides: Overrides, platform: Platform): Keys => { + const keys: Record = { ...defaults } + const claimed = new Set() + for (const action of actions) { + if (!action.rebindable || !isOverridden(overrides, action.id)) continue + const key = overrides[action.id] ?? null + const identity = key === null ? null : identityOf(key, platform) + keys[action.id] = identity !== null && claimed.has(identity) ? null : key + if (identity !== null) claimed.add(identity) + } + for (const action of actions) { + if (!action.rebindable || isOverridden(overrides, action.id) || action.defaultKey === null) + continue + if (claimed.has(identityOf(action.defaultKey, platform))) keys[action.id] = null + } + return keys as Keys + } + + /** Sets `id`'s key, unbinding the rebindable actions that had it. */ + const assign = (overrides: Overrides, id: Id, key: KeyCombo, platform: Platform): Assignment => { + const keys = resolve(overrides, platform) + const identity = identityOf(key, platform) + const sameKey = (other: KeyCombo | null) => other !== null && identityOf(other, platform) === identity + const displaced = actions + .filter((action) => action.rebindable && action.id !== id && sameKey(keys[action.id])) + .map((action) => action.id) + const shadows = actions + .filter( + (action) => + !action.rebindable && + [action.defaultKey, ...action.alsoKeys].some((other) => sameKey(other)), + ) + .map((action) => action.id) + const next: Record = { ...overrides } + for (const other of displaced) next[other] = null + // Back on its default, an action needs no override. + if (byId[id].defaultKey !== null && sameKey(byId[id].defaultKey)) delete next[id] + else next[id] = key + return { overrides: next, displaced, shadows } + } + + /** Leaves `id` with no key. */ + const unbind = (overrides: Overrides, id: Id): Overrides => + byId[id].defaultKey === null ? reset(overrides, id) : { ...overrides, [id]: null } + + /** Back to `id`'s default key. Another action using that key now keeps it; `id` stays unbound then. */ + const reset = (overrides: Overrides, id: Id): Overrides => { + const { [id]: _removed, ...rest } = overrides + return rest + } + + /** Foldkit bindings for the actions a subscription owns; unbound actions are left out. */ + const bindings = ( + keys: Keys, + owned: ReadonlyArray, + options: (id: Owned) => BindingOptions, + ): ReadonlyArray> => + owned.flatMap((id) => { + const key = keys[id] + if (key === null) return [] + const { mapEvent, isEnabled, whileTyping, preventDefault } = options(id) + return [{ keys: key, mapEvent, isEnabled, whileTyping, preventDefault }] + }) + + /** The keys an action answers to now: its key, then its fixed extras. */ + const keysOf = (keys: Keys, id: Id): ReadonlyArray => { + const key = keys[id] + return key === null ? byId[id].alsoKeys : [key, ...byId[id].alsoKeys] + } + + return { + ActionId, + actions, + byId, + defaults, + isActionId, + clean, + isOverridden, + resolve, + assign, + unbind, + reset, + bindings, + keysOf, + } as const +} diff --git a/apps/web-foldkit/src/icons/index.ts b/apps/web-foldkit/src/icons/index.ts index 6cd8ca8fe..c73261b2e 100644 --- a/apps/web-foldkit/src/icons/index.ts +++ b/apps/web-foldkit/src/icons/index.ts @@ -2013,6 +2013,41 @@ export const IconLoader = (h: HtmlBuilder, options: IconOption ], ) +export const IconKeyboard = (h: HtmlBuilder, options: IconOptions = {}): Html => + icon( + h, + { + height: "18", + width: "18", + "data-slot": "icon", + viewBox: "0 0 18 18", + xmlns: "http://www.w3.org/2000/svg", + }, + options, + [ + h.g( + [h.Attribute("fill", "currentColor")], + [ + h.path([ + h.Attribute( + "d", + "M1 6.25C1 4.73122 2.23122 3.5 3.75 3.5H14.25C15.7688 3.5 17 4.73122 17 6.25V11.75C17 13.2688 15.7688 14.5 14.25 14.5H3.75C2.23122 14.5 1 13.2688 1 11.75V6.25Z", + ), + h.Attribute("fill", "currentColor"), + h.Attribute("fill-opacity", "0.4"), + ]), + h.path([ + h.Attribute( + "d", + "M4.5 6.25C4.91421 6.25 5.25 6.58579 5.25 7C5.25 7.41421 4.91421 7.75 4.5 7.75C4.08579 7.75 3.75 7.41421 3.75 7C3.75 6.58579 4.08579 6.25 4.5 6.25ZM7.5 6.25C7.91421 6.25 8.25 6.58579 8.25 7C8.25 7.41421 7.91421 7.75 7.5 7.75C7.08579 7.75 6.75 7.41421 6.75 7C6.75 6.58579 7.08579 6.25 7.5 6.25ZM10.5 6.25C10.9142 6.25 11.25 6.58579 11.25 7C11.25 7.41421 10.9142 7.75 10.5 7.75C10.0858 7.75 9.75 7.41421 9.75 7C9.75 6.58579 10.0858 6.25 10.5 6.25ZM13.5 6.25C13.9142 6.25 14.25 6.58579 14.25 7C14.25 7.41421 13.9142 7.75 13.5 7.75C13.0858 7.75 12.75 7.41421 12.75 7C12.75 6.58579 13.0858 6.25 13.5 6.25ZM6 10.25H12C12.4142 10.25 12.75 10.5858 12.75 11C12.75 11.4142 12.4142 11.75 12 11.75H6C5.58579 11.75 5.25 11.4142 5.25 11C5.25 10.5858 5.58579 10.25 6 10.25Z", + ), + h.Attribute("fill", "currentColor"), + ]), + ], + ), + ], + ) + export const IconLock = (h: HtmlBuilder, options: IconOptions = {}): Html => icon( h, diff --git a/apps/web-foldkit/src/keybindings.ts b/apps/web-foldkit/src/keybindings.ts new file mode 100644 index 000000000..06cbba847 --- /dev/null +++ b/apps/web-foldkit/src/keybindings.ts @@ -0,0 +1,20 @@ +import { Effect, Option, Schema } from "effect" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { type Overrides, StoredOverrides } from "~/lib/keybindings/keymap" +import { readStored, writeStored } from "./data/storage" + +/** + * The user's keybinding overrides, owned by the root (`Shared.keys` is them resolved). Stored per + * device, like the sound settings: a desktop and a laptop keyboard often want different keys. + */ + +const KEYBINDINGS_KEY = "keybinding-overrides" +const StoredCodec = Schema.toCodecIso(Schema.NullOr(StoredOverrides)) + +export const loadKeybindingOverrides: Effect.Effect = readStored( + KEYBINDINGS_KEY, + StoredCodec, +).pipe(Effect.map((stored) => appKeymap.clean(Option.getOrNull(stored) ?? {}))) + +export const saveKeybindingOverrides = (overrides: Overrides) => + writeStored(KEYBINDINGS_KEY, StoredCodec, overrides) diff --git a/apps/web-foldkit/src/main.ts b/apps/web-foldkit/src/main.ts index e281fb5a1..d8c63286f 100644 --- a/apps/web-foldkit/src/main.ts +++ b/apps/web-foldkit/src/main.ts @@ -19,6 +19,7 @@ import { ReplaceUrl, RetryCheckSession, RetryFetchCurrentUser, + SaveKeybindings, SaveSoundSettings, SaveThemePreference, SignOut, @@ -41,6 +42,9 @@ import { MOBILE_QUERY } from "./app/subscription" import { toPageMessage } from "./app/view" import { errorToast, errorToastOf, loadingToast, successToast } from "./data/actions" import { loadSoundSettings, SoundSettings } from "./notification-sound" +import { loadKeybindingOverrides } from "./keybindings" +import { detectPlatform, Platform as KeyboardPlatform } from "~/lib/keybindings/key-combo" +import { Overrides } from "~/lib/keybindings/keymap" import * as CommandPalette from "./overlay/command-palette" import type { LayoutHotkeyActionId } from "./overlay/hotkeys" import * as Modal from "./overlay/modal" @@ -88,6 +92,8 @@ export const Flags = Schema.Struct({ themePreference: ThemePreference, systemTheme: ResolvedTheme, soundSettings: SoundSettings, + keybindingOverrides: Overrides, + keyboardPlatform: KeyboardPlatform, /** `sessionStartTimeAtom`: notifications from before this load never play; also the clock's first value. */ sessionStartMs: Schema.Number, /** `useSidebar().isMobile` at load: the mobile layout renders from the first frame. */ @@ -101,6 +107,8 @@ export const flags: Effect.Effect = Effect.all({ themePreference: loadThemePreference, systemTheme: Effect.sync(resolveSystemTheme), soundSettings: loadSoundSettings, + keybindingOverrides: loadKeybindingOverrides, + keyboardPlatform: Effect.sync(detectPlatform), sessionStartMs: Clock.currentTimeMillis, isMobile: Effect.sync(() => globalThis.matchMedia?.(MOBILE_QUERY).matches ?? false), isCreateChannelHintDismissed: loadIsCreateChannelHintDismissed, @@ -458,6 +466,12 @@ const handleOutMessage = (outMessage: PageOutMessage): Step => command(SaveSoundSettings({ settings })), informPage, ]), + RequestedKeybindings: ({ overrides }) => + Update.combine([ + setModel((model) => modifyFields(model, { keybindingOverrides: () => overrides })), + command(SaveKeybindings({ overrides })), + informPage, + ]), RequestedCurrentUserRefresh: ({ toast }) => Update.combine([showToastIf(toast), refreshCurrentUser]), RequestedUpdateCheck: () => stepUpdates(Updates.check), RequestedUpdateInstall: () => stepUpdates(Updates.install), @@ -682,6 +696,8 @@ export const init: Runtime.RoutingApplicationInit FailedSaveThemePreference: () => savingFailed("Couldn't save your theme")(model), CompletedSaveSoundSettings: () => ({ model }), FailedSaveSoundSettings: () => savingFailed("Couldn't save your sound settings")(model), + CompletedSaveKeybindings: () => ({ model }), + FailedSaveKeybindings: () => savingFailed("Couldn't save your keybinds")(model), UpdatedRecentNotifications: ({ ids }) => ({ model, commands: ids.length === 0 ? [] : [DeliverNotifications({ ids })], diff --git a/apps/web-foldkit/src/overlay/hotkeys.test.ts b/apps/web-foldkit/src/overlay/hotkeys.test.ts index 0285df61e..88a33a104 100644 --- a/apps/web-foldkit/src/overlay/hotkeys.test.ts +++ b/apps/web-foldkit/src/overlay/hotkeys.test.ts @@ -1,7 +1,10 @@ // @vitest-environment jsdom import { describe, expect, test } from "vitest" import { collectKeyStream } from "../test/key-stream" -import { layoutHotkeys } from "./hotkeys" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { combo } from "~/lib/keybindings/key-combo" +import type { Overrides } from "~/lib/keybindings/keymap" +import { layoutHotkeys, layoutKeysOf } from "./hotkeys" /** The org layout's hotkeys from real `keydown`s: Mod is Meta on a Mac, Control elsewhere. */ @@ -14,7 +17,10 @@ const run = ( isOverlayOpen = false, ) => collectKeyStream( - layoutHotkeys((id) => id, { isChannelNavigationEnabled, isOverlayOpen }), + layoutHotkeys((id) => id, layoutKeysOf(appKeymap.defaults), { + isChannelNavigationEnabled, + isOverlayOpen, + }), act, ) @@ -144,3 +150,32 @@ describe("layout hotkeys", () => { editable.remove() }) }) + +describe("rebound layout hotkeys", () => { + const rebound = (overrides: Overrides) => + layoutKeysOf(appKeymap.resolve(overrides, isMac ? "mac" : "linux")) + const runWith = (overrides: Overrides, act: Parameters[1]) => + collectKeyStream( + layoutHotkeys((id) => id, rebound(overrides)), + act, + ) + + test("an action fires on its new key, and no longer on its old one", async () => { + const fired = await runWith({ "inbox.open": combo("Mod+J") }, (press) => { + press(mod({ key: "j" })) + press(mod({ key: "i" })) + }) + expect(fired).toEqual(["inbox.open"]) + }) + + test("an action that lost its key to another, or was unbound, does not fire", async () => { + const fired = await runWith( + { "search.open": combo("Mod+K"), "commandPalette.open": null, "shortcuts.open": null }, + (press) => { + press(mod({ key: "k" })) + press(mod({ key: "/" })) + }, + ) + expect(fired).toEqual(["search.open"]) + }) +}) diff --git a/apps/web-foldkit/src/overlay/hotkeys.ts b/apps/web-foldkit/src/overlay/hotkeys.ts index 68d607aca..def6bff04 100644 --- a/apps/web-foldkit/src/overlay/hotkeys.ts +++ b/apps/web-foldkit/src/overlay/hotkeys.ts @@ -1,11 +1,11 @@ import { Schema } from "effect" import { Dom } from "foldkit" -import { HOTKEY_DEFINITIONS_BY_ID } from "~/lib/hotkeys/hotkey-registry" +import { type AppKeys, appKeymap } from "~/lib/keybindings/app-keymap" +import { KeyCombo } from "~/lib/keybindings/key-combo" /** - * The org layout's `useAppHotkey` bindings, fed from the legacy registry's default hotkeys. - * User overrides (`hotkey-atoms`) are not read yet. Mod combos fire while typing, as in legacy, - * except Mod+I, which is also italic in the composer. + * The org layout's hotkeys, on the user's keys (`Shared.keys`). Mod combos fire while typing, except + * the inbox's, which shares Mod+I with italic in the composer. */ const LAYOUT_ACTIONS = [ "commandPalette.open", @@ -29,10 +29,21 @@ const LAYOUT_ACTIONS = [ "history.forward", ] as const -/** The `AppHotkeyActionId`s the org layout binds. */ +/** The `appKeymap` actions the org layout binds. */ export const LayoutHotkeyActionId = Schema.Literals(LAYOUT_ACTIONS) export type LayoutHotkeyActionId = typeof LayoutHotkeyActionId.Type +/** The layout actions' keys: a subscription dependency, so a rebinding restarts the bindings. */ +export const LayoutKeys = Schema.Struct( + Object.fromEntries(LAYOUT_ACTIONS.map((id) => [id, Schema.NullOr(KeyCombo)])) as { + readonly [K in LayoutHotkeyActionId]: Schema.NullOr + }, +) +export type LayoutKeys = typeof LayoutKeys.Type + +export const layoutKeysOf = (keys: AppKeys): LayoutKeys => + Object.fromEntries(LAYOUT_ACTIONS.map((id) => [id, keys[id]])) as LayoutKeys + /** * Discord's Alt+ArrowUp/Down (Shift: unread, Mod+Shift: mentions): on chat pages, where the only text * field is the composer, so caret moves in other forms keep their keys. @@ -64,6 +75,7 @@ const NOT_WHILE_TYPING: ReadonlySet = new Set(["inbox.open export const layoutHotkeys = ( toMessage: (actionId: LayoutHotkeyActionId) => Message, + keys: LayoutKeys, options: { /** On a chat page, with no modal or palette open. */ readonly isChannelNavigationEnabled: boolean @@ -72,9 +84,8 @@ export const layoutHotkeys = ( } = { isChannelNavigationEnabled: true, isOverlayOpen: false }, ) => Dom.streamFromKeyBindings({ - bindings: LAYOUT_ACTIONS.map((actionId) => ({ - keys: HOTKEY_DEFINITIONS_BY_ID[actionId].defaultHotkey, - whileTyping: NOT_WHILE_TYPING.has(actionId) ? ("Suppress" as const) : ("Allow" as const), + bindings: appKeymap.bindings(keys, LAYOUT_ACTIONS, (actionId) => ({ + whileTyping: NOT_WHILE_TYPING.has(actionId) ? "Suppress" : "Allow", isEnabled: (!CHANNEL_NAVIGATION.has(actionId) || options.isChannelNavigationEnabled) && (!NOT_UNDER_OVERLAY.has(actionId) || !options.isOverlayOpen), diff --git a/apps/web-foldkit/src/overlay/modal/keyboard-shortcuts.ts b/apps/web-foldkit/src/overlay/modal/keyboard-shortcuts.ts index 6fb30c190..30dd1bce1 100644 --- a/apps/web-foldkit/src/overlay/modal/keyboard-shortcuts.ts +++ b/apps/web-foldkit/src/overlay/modal/keyboard-shortcuts.ts @@ -2,16 +2,18 @@ import { Schema } from "effect" import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { defineMessageUnion } from "foldkit/message" -import { SHORTCUT_SECTIONS, type Shortcut } from "~/lib/hotkeys/shortcut-catalog" -import { hotkeyLabel } from "../../shell/channels-sidebar/hotkey-label" +import { type AppActionId, appKeymap, KEYMAP_CATEGORIES } from "~/lib/keybindings/app-keymap" +import { comboLabel, type Platform } from "~/lib/keybindings/key-combo" +import type { Shared } from "../../page/contract" +import { AppRoute } from "../../route" import { button } from "../../ui/button" import { dialogBody, dialogFooter, dialogHeader } from "../../ui/dialog" -import { closed } from "../out-message" +import { closed, completed } from "../out-message" import { defineModal, type ModalReturn, type ModalViewInputs } from "./contract" import { Frame, Message as FrameMessage, frameView, initFrame, isCloseRequest, modalTitle } from "./frame" import * as Requests from "./requests" -/** Discord's Mod+/ "Keyboard Combos": every shortcut, in the platform's notation. */ +/** Discord's Mod+/ "Keyboard Combos": every shortcut on the user's keys, in the platform's notation. */ const Model = Schema.Struct({ frame: Frame }) type Model = typeof Model.Type @@ -19,6 +21,7 @@ type Model = typeof Model.Type export const Message = defineMessageUnion({ GotFrameMessage: { message: FrameMessage }, ClickedClose: {}, + ClickedCustomize: { orgSlug: Schema.String }, }) type Message = typeof Message.Type @@ -29,31 +32,42 @@ const update = (model: Model, message: Message): Return => GotFrameMessage: ({ message }) => isCloseRequest(message) ? { model, outMessage: closed } : { model }, ClickedClose: () => ({ model, outMessage: closed }), + ClickedCustomize: ({ orgSlug }) => ({ + model, + outMessage: completed({ route: AppRoute.MySettingsKeybinds({ orgSlug }) }), + }), }) -const keyCap = (h: HtmlBuilder, combo: string): Html => +const keyCap = (h: HtmlBuilder, combo: string, platform: Platform): Html => h.kbd( [ h.Class( "inline-flex min-w-6 items-center justify-center rounded-md border border-border bg-secondary px-1.5 font-mono text-muted-fg text-xs/5", ), ], - [hotkeyLabel(combo)], + [comboLabel(combo, platform)], ) -const shortcutRow = (h: HtmlBuilder, shortcut: Shortcut): Html => - h.li( +/** Aliases of the unread-channel steps until there is mention data: not listed twice. */ +const UNLISTED: ReadonlySet = new Set(["mention.previous", "mention.next"]) + +const shortcutRow = (h: HtmlBuilder, shared: Shared, id: AppActionId): Html => { + const keys = appKeymap.keysOf(shared.keys, id) + return h.li( [h.Class("flex items-center justify-between gap-4 py-1.5 text-sm")], [ - h.span([h.Class("text-fg")], [shortcut.label]), + h.span([h.Class("text-fg")], [appKeymap.byId[id].label]), h.span( [h.Class("flex shrink-0 items-center gap-1")], - shortcut.keys.map((combo) => keyCap(h, combo)), + keys.length === 0 + ? [h.span([h.Class("text-muted-fg text-xs")], ["Not set"])] + : keys.map((combo) => keyCap(h, combo, shared.keyboardPlatform)), ), ], ) +} -const view = Submodel.defineView((model, _inputs, h) => +const view = Submodel.defineView((model, { shared }, h) => frameView( h, model.frame, @@ -63,9 +77,9 @@ const view = Submodel.defineView((model, _input dialogBody(h, [ h.div( [h.Class("grid gap-x-8 gap-y-6 sm:grid-cols-2")], - SHORTCUT_SECTIONS.map((section) => + KEYMAP_CATEGORIES.map((category) => h.section( - [h.AriaLabel(section.title)], + [h.AriaLabel(category)], [ h.h3( [ @@ -73,18 +87,37 @@ const view = Submodel.defineView((model, _input "mb-1 font-medium text-muted-fg text-xs uppercase tracking-wide", ), ], - [section.title], + [category], ), h.ul( [h.Class("divide-y divide-border")], - section.shortcuts.map((shortcut) => shortcutRow(h, shortcut)), + appKeymap.actions + .filter( + (action) => + action.category === category && !UNLISTED.has(action.id), + ) + .map((action) => shortcutRow(h, shared, action.id)), ), ], ), ), ), ]), - dialogFooter(h, [button(h, { intent: "outline", onPress: Message.ClickedClose() }, ["Close"])]), + dialogFooter(h, [ + ...(shared.orgSlug === null + ? [] + : [ + button( + h, + { + intent: "plain", + onPress: Message.ClickedCustomize({ orgSlug: shared.orgSlug }), + }, + ["Customize keybinds"], + ), + ]), + button(h, { intent: "outline", onPress: Message.ClickedClose() }, ["Close"]), + ]), ], (message) => Message.GotFrameMessage({ message }), ), diff --git a/apps/web-foldkit/src/overlay/toaster.test.ts b/apps/web-foldkit/src/overlay/toaster.test.ts index 721d9bd64..f38db048c 100644 --- a/apps/web-foldkit/src/overlay/toaster.test.ts +++ b/apps/web-foldkit/src/overlay/toaster.test.ts @@ -49,6 +49,8 @@ describe("root toaster", () => { sessionStartMs: 0, isMobile: false, isCreateChannelHintDismissed: false, + keybindingOverrides: {}, + keyboardPlatform: "linux", }, url, ), diff --git a/apps/web-foldkit/src/page/chat/channel/hotkeys.ts b/apps/web-foldkit/src/page/chat/channel/hotkeys.ts index afa9c51a4..0fc433666 100644 --- a/apps/web-foldkit/src/page/chat/channel/hotkeys.ts +++ b/apps/web-foldkit/src/page/chat/channel/hotkeys.ts @@ -1,9 +1,12 @@ import { Schema } from "effect" import { Dom } from "foldkit" +import { type AppKeys, appKeymap } from "~/lib/keybindings/app-keymap" +import { KeyCombo } from "~/lib/keybindings/key-combo" /** - * Discord's channel shortcuts that this page owns. The Mod combos and the page keys work while typing - * in the composer: the editor leaves them alone (`editor/behavior.ts`), so they reach the document. + * Discord's channel shortcuts that this page owns, on the user's keys (`Shared.keys`). The Mod combos + * and the page keys work while typing in the composer: the editor leaves them alone + * (`editor/behavior.ts`), so they reach the document. */ export const ChannelHotkey = Schema.Literals([ @@ -19,36 +22,55 @@ export type ChannelHotkey = typeof ChannelHotkey.Type interface ChannelBinding { readonly action: ChannelHotkey - readonly keys: string /** Also from the composer and other fields (the Mod combos, the page keys). */ readonly whileTyping: boolean /** Still bound while one of the page's dialogs (a picker, a menu) holds the keyboard. */ readonly inDialogs: boolean } -/** Each shortcut's keys, as Discord binds them. */ -export const CHANNEL_HOTKEYS: ReadonlyArray = [ - { action: "EmojiPicker", keys: "Mod+E", whileTyping: true, inDialogs: true }, - { action: "GifPicker", keys: "Mod+G", whileTyping: true, inDialogs: true }, - { action: "Upload", keys: "Mod+Shift+U", whileTyping: true, inDialogs: false }, - { action: "Pins", keys: "Mod+P", whileTyping: true, inDialogs: true }, - { action: "ScrollUp", keys: "PageUp", whileTyping: true, inDialogs: false }, - { action: "ScrollDown", keys: "PageDown", whileTyping: true, inDialogs: false }, +/** Each `appKeymap` action this page binds, and how. */ +const CHANNEL_BINDINGS = { + "channel.emojiPicker": { action: "EmojiPicker", whileTyping: true, inDialogs: true }, + "channel.gifPicker": { action: "GifPicker", whileTyping: true, inDialogs: true }, + "channel.upload": { action: "Upload", whileTyping: true, inDialogs: false }, + "channel.pins": { action: "Pins", whileTyping: true, inDialogs: true }, + "channel.scrollUp": { action: "ScrollUp", whileTyping: true, inDialogs: false }, + "channel.scrollDown": { action: "ScrollDown", whileTyping: true, inDialogs: false }, // Escape in the composer reaches the draft first; with nothing to cancel it marks read there. - { action: "MarkRead", keys: "Escape", whileTyping: false, inDialogs: false }, -] + "channel.markRead": { action: "MarkRead", whileTyping: false, inDialogs: false }, +} as const satisfies Record + +type ChannelActionId = keyof typeof CHANNEL_BINDINGS +const CHANNEL_ACTIONS = Object.keys(CHANNEL_BINDINGS) as ReadonlyArray + +/** The page's keys: a subscription dependency, so a rebinding restarts the bindings. */ +export const ChannelKeys = Schema.Struct( + Object.fromEntries(CHANNEL_ACTIONS.map((id) => [id, Schema.NullOr(KeyCombo)])) as { + readonly [K in ChannelActionId]: Schema.NullOr + }, +) +export type ChannelKeys = typeof ChannelKeys.Type + +export const channelKeysOf = (keys: AppKeys): ChannelKeys => + Object.fromEntries(CHANNEL_ACTIONS.map((id) => [id, keys[id]])) as ChannelKeys export const channelHotkeysStream = ( toMessage: (action: ChannelHotkey) => Message, - options: { readonly isDialogOpen: boolean }, + options: { readonly isDialogOpen: boolean; readonly keys?: ChannelKeys }, ) => Dom.streamFromKeyBindings({ - bindings: CHANNEL_HOTKEYS.map(({ action, keys, whileTyping, inDialogs }) => ({ - keys, - whileTyping: whileTyping ? ("Allow" as const) : ("Suppress" as const), - isEnabled: inDialogs || !options.isDialogOpen, - // Escape is only noticed: whatever else listens for it still gets it. - preventDefault: action !== "MarkRead", - mapEvent: () => toMessage(action), - })), + bindings: appKeymap.bindings( + options.keys ?? channelKeysOf(appKeymap.defaults), + CHANNEL_ACTIONS, + (id) => { + const { action, whileTyping, inDialogs } = CHANNEL_BINDINGS[id] + return { + whileTyping: whileTyping ? "Allow" : "Suppress", + isEnabled: inDialogs || !options.isDialogOpen, + // Escape is only noticed: whatever else listens for it still gets it. + preventDefault: action !== "MarkRead", + mapEvent: () => toMessage(action), + } + }, + ), }) diff --git a/apps/web-foldkit/src/page/chat/channel/subscription.ts b/apps/web-foldkit/src/page/chat/channel/subscription.ts index 854fc7c89..c4f3758bd 100644 --- a/apps/web-foldkit/src/page/chat/channel/subscription.ts +++ b/apps/web-foldkit/src/page/chat/channel/subscription.ts @@ -40,7 +40,7 @@ import { uploadStream } from "../../../composer/upload" import { MessageWindows } from "../../../data/message-windows" import type { HazelApiClient, HazelRpc } from "../../../rpc" import { globalPasteStream, globalTypingStream, leftWindowStream } from "../../../composer/window-events" -import { channelHotkeysStream } from "./hotkeys" +import { ChannelKeys, channelHotkeysStream, channelKeysOf } from "./hotkeys" import { isMemberOf, Message, type Model } from "./page" import { windowFocus } from "../../../platform/presence/activity" @@ -475,17 +475,19 @@ const write = Subscription.make()((en ), // Discord's channel shortcuts (`hotkeys.ts`), while the page (not a root overlay) holds the keyboard. channelHotkeys: entry( - { isActive: Schema.Boolean, isDialogOpen: Schema.Boolean }, + { isActive: Schema.Boolean, isDialogOpen: Schema.Boolean, keys: ChannelKeys }, { modelToDependencies: ({ model, shared }) => ({ isActive: model.tab._tag === "Messages" && isMemberOf(model) !== false && !shared.isOverlayOpen, isDialogOpen: hasOpenDialog(model), + keys: channelKeysOf(shared.keys), }), - dependenciesToStream: ({ isActive, isDialogOpen }) => + dependenciesToStream: ({ isActive, isDialogOpen, keys }) => isActive ? channelHotkeysStream((action) => Message.PressedChannelHotkey({ action }), { isDialogOpen, + keys, }) : Stream.empty, }, diff --git a/apps/web-foldkit/src/page/contract.ts b/apps/web-foldkit/src/page/contract.ts index cc7964046..3f402fdda 100644 --- a/apps/web-foldkit/src/page/contract.ts +++ b/apps/web-foldkit/src/page/contract.ts @@ -6,6 +6,9 @@ import type { Resources } from "../rpc" import type { AppRoute, RouteOf, RouteTag } from "../route" import type { Auth, CurrentUser, Member, Organization } from "../session" import type { SoundSettings } from "../notification-sound" +import type { AppKeys } from "~/lib/keybindings/app-keymap" +import type { Platform } from "~/lib/keybindings/key-combo" +import type { Overrides } from "~/lib/keybindings/keymap" import type { ThemeState } from "../theme" import type * as Updates from "../platform/updates" import type { PageOutMessage } from "./out-message" @@ -29,6 +32,11 @@ export interface Shared { readonly theme: ThemeState /** `notificationSoundSettingsAtom`: what the sound sink and "Test sound" use. */ readonly soundSettings: SoundSettings + /** The key of every action in `appKeymap`, with the user's overrides applied. */ + readonly keys: AppKeys + /** The overrides themselves (Keybinds settings): which actions the user changed. */ + readonly keybindingOverrides: Overrides + readonly keyboardPlatform: Platform /** The desktop app's update check and install (always `Idle` on the web). */ readonly desktopUpdate: Updates.Model /** A root modal or the command palette is open (it holds the keyboard; the composer's global typing waits). */ diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/index.ts b/apps/web-foldkit/src/page/my-settings/keybinds/index.ts new file mode 100644 index 000000000..5626841f2 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/index.ts @@ -0,0 +1,13 @@ +import { definePage } from "../../contract" +import { Message } from "./message" +import { Model } from "./model" +import { subscriptions } from "./subscription" +import { init, update } from "./update" +import { view } from "./view" + +/** `/$orgSlug/my-settings/keybinds`: Discord's Keybinds settings. */ +export const page = definePage( + "MySettingsKeybinds", + { Model, Message }, + { routes: ["MySettingsKeybinds"], init, update, view, subscriptions }, +) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/message.ts b/apps/web-foldkit/src/page/my-settings/keybinds/message.ts new file mode 100644 index 000000000..014c05b32 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/message.ts @@ -0,0 +1,20 @@ +import { Schema } from "effect" +import { defineMessageUnion } from "foldkit/message" +import { KeyCombo, MODIFIER_ORDER } from "~/lib/keybindings/key-combo" +import { ActionId } from "./model" + +export const Message = defineMessageUnion({ + ChangedQuery: { value: Schema.String }, + ClearedQuery: {}, + ClickedRecord: { actionId: ActionId }, + /** While recording: the modifiers now held (a press or release of one). */ + ChangedHeldModifiers: { modifiers: Schema.Array(Schema.Literals(MODIFIER_ORDER)) }, + /** While recording: a key landed, with the modifiers held. */ + PressedCombo: { combo: KeyCombo }, + /** Escape, a click elsewhere, or the window losing focus. */ + CancelledRecording: {}, + ClickedReset: { actionId: ActionId }, + ClickedUnbind: { actionId: ActionId }, + ClickedResetAll: {}, +}) +export type Message = typeof Message.Type diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/model.ts b/apps/web-foldkit/src/page/my-settings/keybinds/model.ts new file mode 100644 index 000000000..7143afe77 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/model.ts @@ -0,0 +1,25 @@ +import { Schema } from "effect" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { MODIFIER_ORDER } from "~/lib/keybindings/key-combo" + +export const ActionId = appKeymap.ActionId +export type ActionId = typeof ActionId.Type + +/** What the last change did to other actions, shown under its row until the next change. */ +export const Notice = Schema.Struct({ + actionId: ActionId, + tone: Schema.Literals(["info", "warning"]), + text: Schema.String, +}) +export type Notice = typeof Notice.Type + +export const Model = Schema.Struct({ + /** Filters the actions by label, description and key. */ + query: Schema.String, + /** The action whose new key is being recorded: the next key press becomes it. */ + recording: Schema.NullOr(ActionId), + /** The modifiers held while recording, shown live before the key lands. */ + held: Schema.Array(Schema.Literals(MODIFIER_ORDER)), + notice: Schema.NullOr(Notice), +}) +export type Model = typeof Model.Type diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/recorder.test.ts b/apps/web-foldkit/src/page/my-settings/keybinds/recorder.test.ts new file mode 100644 index 000000000..a2a3747d9 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/recorder.test.ts @@ -0,0 +1,54 @@ +// @vitest-environment jsdom +import { describe, expect, test } from "vitest" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { combo } from "~/lib/keybindings/key-combo" +import { collectKeyStream } from "../../../test/key-stream" +import { layoutHotkeys, layoutKeysOf } from "../../../overlay/hotkeys" +import { Message } from "./message" +import { recorderStream } from "./subscription" +import { Effect, Fiber, Stream } from "effect" + +/** The recorder from real `keydown`s: it takes every press before the app's own hotkeys see it. */ + +const ctrl = (init: KeyboardEventInit): KeyboardEventInit => ({ ...init, ctrlKey: true }) + +describe("recorder", () => { + test("a modifier alone shows as held; the key lands as a Mod combo", async () => { + const recorded = await collectKeyStream(recorderStream("linux"), (press) => { + press({ key: "Control", ctrlKey: true }) + press(ctrl({ key: "j" })) + }) + expect(recorded).toEqual([ + Message.ChangedHeldModifiers({ modifiers: ["Mod"] }), + Message.PressedCombo({ combo: combo("Mod+J") }), + ]) + }) + + test("Escape alone cancels; Shift+Escape is a key like any other", async () => { + const recorded = await collectKeyStream(recorderStream("linux"), (press) => { + press({ key: "Escape" }) + press({ key: "Escape", shiftKey: true }) + }) + expect(recorded).toEqual([ + Message.CancelledRecording(), + Message.PressedCombo({ combo: combo("Shift+Escape") }), + ]) + }) + + test("while recording, the app's hotkeys never see the press", async () => { + const fromHotkeys: Array = [] + const hotkeys = Effect.runFork( + Stream.runForEach( + layoutHotkeys((id) => id, layoutKeysOf(appKeymap.defaults)), + (id) => Effect.sync(() => fromHotkeys.push(id)), + ), + ) + const recorded = await collectKeyStream(recorderStream("linux"), (press) => { + const event = press(ctrl({ key: "k" })) + expect(event.defaultPrevented).toBe(true) + }) + await Effect.runPromise(Fiber.interrupt(hotkeys)) + expect(recorded).toEqual([Message.PressedCombo({ combo: combo("Mod+K") })]) + expect(fromHotkeys).toEqual([]) + }) +}) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts new file mode 100644 index 000000000..9868e143f --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts @@ -0,0 +1,151 @@ +// @vitest-environment jsdom +import * as Scene from "foldkit/scene" +import { describe, expect, test } from "vitest" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { combo } from "~/lib/keybindings/key-combo" +import type { Overrides } from "~/lib/keybindings/keymap" +import { makeShared, pageScene } from "../../../test/pages-fixtures" +import { PageOutMessage } from "../../out-message" +import { Message } from "./message" +import { init, update } from "./update" +import { view } from "./view" + +/** The Keybinds page: press a row's key button, press keys, and the root stores the new key. */ + +const sharedWith = (overrides: Overrides) => + makeShared({ + keybindingOverrides: overrides, + keys: appKeymap.resolve(overrides, "linux"), + keyboardPlatform: "linux", + }) + +const keyButton = (label: string) => Scene.role("button", { name: new RegExp(`^${label}:`) }) + +describe("recording a key", () => { + test("pressing a row's key button records the next combo and asks the root to store it", () => { + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given(init().model), + Scene.expect(keyButton("Open Inbox")).toHaveAccessibleName( + "Open Inbox: Ctrl+I. Press to change.", + ), + Scene.click(keyButton("Open Inbox")), + Scene.expect(Scene.text("Press a shortcut")).toExist(), + Scene.Subscription.emit(Message.ChangedHeldModifiers({ modifiers: ["Mod"] })), + Scene.expect(Scene.text("Now a key")).toExist(), + Scene.Subscription.emit(Message.PressedCombo({ combo: combo("Mod+J") })), + Scene.expectOutMessage( + PageOutMessage.RequestedKeybindings({ overrides: { "inbox.open": combo("Mod+J") } }), + ), + Scene.expect(Scene.text("Press a shortcut")).toBeAbsent(), + ) + }) + + test("a key another action had moves, and the row says where it came from", () => { + const result = update( + update(init().model, Message.ClickedRecord({ actionId: "inbox.open" }), sharedWith({})).model, + Message.PressedCombo({ combo: combo("Mod+K") }), + sharedWith({}), + ) + expect(result.outMessage).toEqual( + PageOutMessage.RequestedKeybindings({ + overrides: { "inbox.open": combo("Mod+K"), "commandPalette.open": null }, + }), + ) + expect(result.model.notice).toEqual({ + actionId: "inbox.open", + tone: "info", + text: "Ctrl+K was removed from “Open Command Palette”.", + }) + }) + + test("a key a built-in shortcut uses is allowed, with a warning", () => { + const recording = update( + init().model, + Message.ClickedRecord({ actionId: "channel.pins" }), + sharedWith({}), + ) + const result = update( + recording.model, + Message.PressedCombo({ combo: combo("Mod+B") }), + sharedWith({}), + ) + expect(result.model.notice).toMatchObject({ + tone: "warning", + text: expect.stringContaining("“Bold”"), + }) + }) + + test("Escape (or a click elsewhere) cancels without saving", () => { + const recording = update( + init().model, + Message.ClickedRecord({ actionId: "inbox.open" }), + sharedWith({}), + ) + const cancelled = update(recording.model, Message.CancelledRecording(), sharedWith({})) + expect(cancelled.model.recording).toBeNull() + expect(cancelled.outMessage).toBeUndefined() + }) + + test("a built-in shortcut cannot be recorded", () => { + expect( + update(init().model, Message.ClickedRecord({ actionId: "composer.bold" }), sharedWith({})).model + .recording, + ).toBeNull() + }) +}) + +describe("reset and remove", () => { + const custom = sharedWith({ "inbox.open": combo("Mod+J") }) + + test("a changed row shows Custom and resets to its default", () => { + Scene.scene( + pageScene(update, view, custom), + Scene.given(init().model), + Scene.expect(Scene.text("1 customized")).toExist(), + Scene.click(Scene.role("button", { name: "Reset to default (Ctrl+I)" })), + Scene.expectOutMessage(PageOutMessage.RequestedKeybindings({ overrides: {} })), + ) + }) + + test("Remove keybind leaves the action unbound", () => { + expect( + update(init().model, Message.ClickedUnbind({ actionId: "channel.pins" }), sharedWith({})) + .outMessage, + ).toEqual(PageOutMessage.RequestedKeybindings({ overrides: { "channel.pins": null } })) + }) + + test("Reset all clears every override", () => { + Scene.scene( + pageScene(update, view, custom), + Scene.given(init().model), + Scene.click(Scene.role("button", { name: "Reset all" })), + Scene.expectOutMessage(PageOutMessage.RequestedKeybindings({ overrides: {} })), + ) + }) + + test("with nothing changed, Reset all is disabled", () => { + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given(init().model), + Scene.expect(Scene.role("button", { name: "Reset all" })).toBeDisabled(), + ) + }) +}) + +describe("search", () => { + test("filters by name and by key", () => { + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given({ ...init().model, query: "inbox" }), + Scene.expect(keyButton("Open Inbox")).toExist(), + Scene.expect(keyButton("Open Command Palette")).toBeAbsent(), + ) + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given({ ...init().model, query: "ctrl+k" }), + Scene.expect(keyButton("Open Command Palette")).toExist(), + Scene.expect(keyButton("Open Inbox")).toBeAbsent(), + ) + }) +}) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/subscription.ts b/apps/web-foldkit/src/page/my-settings/keybinds/subscription.ts new file mode 100644 index 000000000..dbcaed9bd --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/subscription.ts @@ -0,0 +1,82 @@ +import { Effect, Option, Queue, Schema, Stream } from "effect" +import { Subscription } from "foldkit" +import { comboOfPress, heldModifiers, MODIFIER_ORDER, Platform } from "~/lib/keybindings/key-combo" +import type { PageSubscriptionInput } from "../../contract" +import { Message } from "./message" +import type { Model } from "./model" + +/** The element a click inside keeps the recording going (the row's key button). */ +export const RECORDER_ATTRIBUTE = "data-keybind-recorder" + +const modifiersOf = (event: KeyboardEvent, platform: Platform) => { + const held = heldModifiers(event, platform) + return MODIFIER_ORDER.filter((modifier) => held.has(modifier)) +} + +/** + * While recording, every key press is the recording's: caught on the window before anything else + * (the app's own hotkeys, the browser's), so ⌘K records ⌘K instead of opening the palette. Escape + * alone cancels; a click elsewhere or leaving the window does too. + */ +export const recorderStream = (platform: Platform) => + Stream.callback((queue) => + Effect.acquireRelease( + Effect.sync(() => { + const onKeyDown = (event: KeyboardEvent) => { + event.preventDefault() + event.stopImmediatePropagation() + if (event.isComposing || event.repeat) return + const noModifiers = !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + if (event.key === "Escape" && noModifiers) + return Queue.offerUnsafe(queue, Message.CancelledRecording()) + Option.match(comboOfPress(event, platform), { + onNone: () => + Queue.offerUnsafe( + queue, + Message.ChangedHeldModifiers({ modifiers: modifiersOf(event, platform) }), + ), + onSome: (combo) => Queue.offerUnsafe(queue, Message.PressedCombo({ combo })), + }) + } + const onKeyUp = (event: KeyboardEvent) => { + event.preventDefault() + event.stopImmediatePropagation() + Queue.offerUnsafe( + queue, + Message.ChangedHeldModifiers({ modifiers: modifiersOf(event, platform) }), + ) + } + const onPointerDown = (event: PointerEvent) => { + const inside = + event.target instanceof Element && event.target.closest(`[${RECORDER_ATTRIBUTE}]`) + if (!inside) Queue.offerUnsafe(queue, Message.CancelledRecording()) + } + const onBlur = () => Queue.offerUnsafe(queue, Message.CancelledRecording()) + window.addEventListener("keydown", onKeyDown, true) + window.addEventListener("keyup", onKeyUp, true) + window.addEventListener("pointerdown", onPointerDown, true) + window.addEventListener("blur", onBlur) + return () => { + window.removeEventListener("keydown", onKeyDown, true) + window.removeEventListener("keyup", onKeyUp, true) + window.removeEventListener("pointerdown", onPointerDown, true) + window.removeEventListener("blur", onBlur) + } + }), + (release) => Effect.sync(release), + ).pipe(Effect.flatMap(() => Effect.never)), + ) + +export const subscriptions = Subscription.make, Message>()((entry) => ({ + recorder: entry( + { isRecording: Schema.Boolean, platform: Platform }, + { + modelToDependencies: ({ model, shared }) => ({ + isRecording: model.recording !== null, + platform: shared.keyboardPlatform, + }), + dependenciesToStream: ({ isRecording, platform }) => + isRecording ? recorderStream(platform) : Stream.empty, + }, + ), +})) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/update.ts b/apps/web-foldkit/src/page/my-settings/keybinds/update.ts new file mode 100644 index 000000000..b607488e2 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/update.ts @@ -0,0 +1,82 @@ +import { modifyFields } from "foldkit/struct" +import { appKeymap } from "~/lib/keybindings/app-keymap" +import { comboLabel, type KeyCombo } from "~/lib/keybindings/key-combo" +import type { Overrides } from "~/lib/keybindings/keymap" +import type { PageReturn, Shared } from "../../contract" +import { PageOutMessage } from "../../out-message" +import { Message } from "./message" +import type { ActionId, Model, Notice } from "./model" + +type Return = PageReturn + +export const init = (): Return => ({ + model: { query: "", recording: null, held: [], notice: null }, +}) + +const stopped = (model: Model, notice: Notice | null): Model => + modifyFields(model, { recording: () => null, held: () => [], notice: () => notice }) + +const save = (model: Model, overrides: Overrides): Return => ({ + model, + outMessage: PageOutMessage.RequestedKeybindings({ overrides }), +}) + +const labels = (ids: ReadonlyArray) => ids.map((id) => `“${appKeymap.byId[id].label}”`).join(", ") + +/** What a new key took from other actions, or what it may lose to. */ +const noticeOf = ( + actionId: ActionId, + key: KeyCombo, + displaced: ReadonlyArray, + shadows: ReadonlyArray, + shared: Shared, +): Notice | null => { + const label = comboLabel(key, shared.keyboardPlatform) + if (shadows.length > 0) + return { + actionId, + tone: "warning", + text: `${label} is also ${labels(shadows)}, so it may not work there.`, + } + if (displaced.length > 0) + return { actionId, tone: "info", text: `${label} was removed from ${labels(displaced)}.` } + return null +} + +const recorded = (model: Model, key: KeyCombo, shared: Shared): Return => { + const actionId = model.recording + if (actionId === null) return { model } + const { overrides, displaced, shadows } = appKeymap.assign( + shared.keybindingOverrides, + actionId, + key, + shared.keyboardPlatform, + ) + return save(stopped(model, noticeOf(actionId, key, displaced, shadows, shared)), overrides) +} + +export const update = (model: Model, message: Message, shared: Shared): Return => + Message.match(message, { + ChangedQuery: ({ value }) => ({ model: modifyFields(model, { query: () => value }) }), + ClearedQuery: () => ({ model: modifyFields(model, { query: () => "" }) }), + ClickedRecord: ({ actionId }) => + appKeymap.byId[actionId].rebindable + ? { + model: modifyFields(model, { + // A second click on the recording row stops it. + recording: () => (model.recording === actionId ? null : actionId), + held: () => [], + notice: () => null, + }), + } + : { model }, + ChangedHeldModifiers: ({ modifiers }) => + model.recording === null ? { model } : { model: modifyFields(model, { held: () => modifiers }) }, + PressedCombo: ({ combo }) => recorded(model, combo, shared), + CancelledRecording: () => ({ model: stopped(model, model.notice) }), + ClickedReset: ({ actionId }) => + save(stopped(model, null), appKeymap.reset(shared.keybindingOverrides, actionId)), + ClickedUnbind: ({ actionId }) => + save(stopped(model, null), appKeymap.unbind(shared.keybindingOverrides, actionId)), + ClickedResetAll: () => save(stopped(model, null), {}), + }) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts new file mode 100644 index 000000000..e739228e2 --- /dev/null +++ b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts @@ -0,0 +1,331 @@ +import { Submodel } from "foldkit" +import type { Html, HtmlBuilder } from "foldkit/html" +import { twJoin } from "tailwind-merge" +import { appKeymap, KEYMAP_CATEGORIES } from "~/lib/keybindings/app-keymap" +import { comboLabel, keyCaps, type KeyCombo, modifierCaps, type Platform } from "~/lib/keybindings/key-combo" +import { IconArrowPath, IconClose, IconLock, IconWarning } from "../../../icons" +import { badge } from "../../../ui/badge" +import { ariaButton, button } from "../../../ui/button" +import { searchField } from "../../../ui/search-field" +import type { PageViewInputs, Shared } from "../../contract" +import { pageHeader } from "../shared" +import { Message } from "./message" +import type { Model } from "./model" +import { RECORDER_ATTRIBUTE } from "./subscription" + +type H = HtmlBuilder +type AppAction = (typeof appKeymap.actions)[number] + +// KEY CAPS + +const cap = (h: H, label: string, tone: "default" | "live" = "default"): Html => + h.kbd( + [ + h.Class( + twJoin( + "inline-flex h-6 min-w-6 items-center justify-center rounded-md border px-1.5 font-mono text-xs/none", + "shadow-[inset_0_-1px_0_var(--color-border)]", + tone === "live" + ? "border-primary/40 bg-primary/10 text-primary" + : "border-border bg-bg text-fg", + ), + ), + ], + [label], + ) + +const caps = (h: H, key: KeyCombo, platform: Platform): Html => + h.span( + [h.Class("flex items-center gap-1")], + keyCaps(key, platform).map((label) => cap(h, label)), + ) + +// MATCHING + +const matches = (action: AppAction, query: string, shared: Shared): boolean => { + const needle = query.trim().toLowerCase() + if (needle === "") return true + const keys = appKeymap.keysOf(shared.keys, action.id) + return [ + action.label, + action.description, + action.category, + ...keys.map((key) => comboLabel(key, shared.keyboardPlatform)), + ] + .join(" ") + .toLowerCase() + .includes(needle) +} + +// ROW PARTS + +/** The key button: shows the key, and records a new one when pressed. */ +const recorder = (h: H, model: Model, action: AppAction, shared: Shared): Html => { + const isRecording = model.recording === action.id + const key = shared.keys[action.id] + const live = modifierCaps(new Set(model.held), shared.keyboardPlatform) + const content: Array = isRecording + ? [ + ...live.map((label) => cap(h, label, "live")), + h.span( + [h.Class("flex items-center gap-1.5 text-primary text-xs")], + [ + h.span( + [h.Class("relative flex size-2")], + [ + h.span([ + h.Class( + "absolute inline-flex size-full animate-ping rounded-full bg-primary opacity-60 motion-reduce:hidden", + ), + ]), + h.span([h.Class("relative inline-flex size-2 rounded-full bg-primary")]), + ], + ), + live.length === 0 ? "Press a shortcut" : "Now a key", + ], + ), + ] + : key === null + ? [h.span([h.Class("text-muted-fg text-xs")], ["Not set"])] + : [caps(h, key, shared.keyboardPlatform)] + return ariaButton( + h, + { + className: twJoin( + "flex h-9 min-w-36 items-center justify-end gap-1 rounded-lg border px-2 outline-none transition-colors duration-150", + "focus-visible:ring-2 focus-visible:ring-ring/40", + isRecording + ? "border-primary bg-primary/5 ring-4 ring-primary/15" + : "border-transparent hover:border-border hover:bg-secondary/50", + ), + onPress: Message.ClickedRecord({ actionId: action.id }), + attributes: [ + h.Attribute(RECORDER_ATTRIBUTE, ""), + h.AriaLabel( + isRecording + ? `Recording a new key for ${action.label}. Press Escape to cancel.` + : `${action.label}: ${key === null ? "not set" : comboLabel(key, shared.keyboardPlatform)}. Press to change.`, + ), + ...(isRecording ? [h.Attribute("aria-pressed", "true")] : []), + ], + }, + content, + ) +} + +const iconButton = (h: H, label: string, onPress: Message, icon: Html, className?: string): Html => + button( + h, + { + intent: "plain", + size: "sq-sm", + className, + onPress, + attributes: [h.AriaLabel(label), h.Attribute("title", label)], + }, + [icon], + ) + +/** Reset and unbind, in fixed slots so the key buttons line up whichever show. */ +const rowActions = (h: H, action: AppAction, shared: Shared): Html => { + const isOverridden = appKeymap.isOverridden(shared.keybindingOverrides, action.id) + const isBound = shared.keys[action.id] !== null + const defaultLabel = + action.defaultKey === null ? "none" : comboLabel(action.defaultKey, shared.keyboardPlatform) + return h.div( + [h.Class("flex w-16 shrink-0 items-center justify-end gap-0.5")], + [ + isOverridden + ? iconButton( + h, + `Reset to default (${defaultLabel})`, + Message.ClickedReset({ actionId: action.id }), + IconArrowPath(h), + ) + : h.span([h.Class("size-7")]), + isBound + ? iconButton( + h, + "Remove keybind", + Message.ClickedUnbind({ actionId: action.id }), + IconClose(h), + // Quiet until the row is hovered or focused; always there on touch screens. + "opacity-0 transition-opacity duration-150 group-hover/row:opacity-100 group-focus-within/row:opacity-100 pointer-coarse:opacity-100", + ) + : h.span([h.Class("size-7")]), + ], + ) +} + +const noticeLine = (h: H, model: Model, action: AppAction): Array => { + const notice = model.notice + if (notice === null || notice.actionId !== action.id) return [] + return [ + h.p( + [ + h.Class( + twJoin( + "col-span-full flex items-center gap-1.5 text-xs", + notice.tone === "warning" ? "text-warning-subtle-fg" : "text-muted-fg", + ), + ), + h.Attribute("role", "status"), + ], + [ + ...(notice.tone === "warning" ? [IconWarning(h, { className: "size-3.5 shrink-0" })] : []), + notice.text, + ], + ), + ] +} + +const rowLabel = (h: H, action: AppAction, isCustom: boolean): Html => + h.div( + [h.Class("flex min-w-0 flex-col gap-0.5")], + [ + h.div( + [h.Class("flex items-center gap-2")], + [ + h.span([h.Class("truncate font-medium text-fg text-sm")], [action.label]), + ...(isCustom ? [badge(h, { intent: "primary", size: "sm" }, ["Custom"])] : []), + ], + ), + h.span([h.Class("truncate text-muted-fg text-xs")], [action.description]), + ], + ) + +const editableRow = (h: H, model: Model, action: AppAction, shared: Shared): Html => + h.li( + [ + h.Class( + twJoin( + "group/row grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-1.5 px-4 py-2.5 transition-colors duration-150", + model.recording === action.id && "bg-primary/[0.03]", + ), + ), + ], + [ + rowLabel(h, action, appKeymap.isOverridden(shared.keybindingOverrides, action.id)), + recorder(h, model, action, shared), + rowActions(h, action, shared), + ...noticeLine(h, model, action), + ], + ) + +/** A built-in key: listed so you know it is taken, not changeable. */ +const fixedRow = (h: H, action: AppAction, shared: Shared): Html => + h.li( + [h.Class("grid grid-cols-[1fr_auto_auto] items-center gap-x-3 px-4 py-2.5")], + [ + rowLabel(h, action, false), + h.span( + [h.Class("flex min-w-36 items-center justify-end gap-2 px-2")], + appKeymap.keysOf(shared.keys, action.id).map((key) => caps(h, key, shared.keyboardPlatform)), + ), + h.span( + [h.Class("flex w-16 justify-end pr-2 text-muted-fg"), h.Attribute("title", "Built in")], + [IconLock(h, { className: "size-3.5", title: "Built in" })], + ), + ], + ) + +// SECTIONS + +const section = ( + h: H, + model: Model, + category: string, + actions: ReadonlyArray, + shared: Shared, +): Html => { + const isFixed = actions.every((action) => !action.rebindable) + return h.section( + [h.AriaLabel(category), h.Class("flex flex-col gap-2")], + [ + h.div( + [h.Class("flex items-baseline justify-between px-1")], + [ + h.h3([h.Class("font-medium text-muted-fg text-xs uppercase tracking-wide")], [category]), + ...(isFixed ? [h.span([h.Class("text-muted-fg text-xs")], ["Built in"])] : []), + ], + ), + h.ul( + [h.Class("divide-y divide-border overflow-hidden rounded-xl border border-border bg-bg")], + actions.map((action) => + action.rebindable ? editableRow(h, model, action, shared) : fixedRow(h, action, shared), + ), + ), + ], + ) +} + +const toolbar = (h: H, model: Model, shared: Shared): Html => { + const customized = Object.keys(shared.keybindingOverrides).length + return h.div( + [h.Class("flex flex-wrap items-center gap-3")], + [ + h.div( + [h.Class("w-72 max-md:w-full")], + [ + searchField( + h, + { + id: "keybinds-search", + value: model.query, + onInput: (value) => Message.ChangedQuery({ value }), + onClear: Message.ClearedQuery(), + ariaLabel: "Search shortcuts", + }, + (parts) => [parts.searchInput({ placeholder: "Search shortcuts" })], + ), + ], + ), + h.span( + [h.Class("ml-auto text-muted-fg text-xs")], + [customized === 0 ? "All defaults" : `${customized} customized`], + ), + button( + h, + { + intent: "outline", + size: "sm", + isDisabled: customized === 0, + onPress: Message.ClickedResetAll(), + }, + ["Reset all"], + ), + ], + ) +} + +export const view = Submodel.defineView((model, { shared }, h) => { + const sections = KEYMAP_CATEGORIES.flatMap((category) => { + const actions = appKeymap.actions.filter( + (action) => action.category === category && matches(action, model.query, shared), + ) + return actions.length === 0 ? [] : [section(h, model, category, actions, shared)] + }) + return h.div( + [h.Class("flex flex-col gap-6 px-4 pb-8 lg:px-8")], + [ + pageHeader( + h, + "Keybinds", + "Press a shortcut to record a new one. Changes apply right away, on this device.", + ), + toolbar(h, model, shared), + ...(sections.length === 0 + ? [ + h.p( + [ + h.Class( + "rounded-xl border border-dashed border-border px-4 py-10 text-center text-muted-fg text-sm", + ), + ], + [`No shortcuts match “${model.query.trim()}”.`], + ), + ] + : sections), + ], + ) +}) diff --git a/apps/web-foldkit/src/page/out-message.ts b/apps/web-foldkit/src/page/out-message.ts index 7d95c0950..cb5c53181 100644 --- a/apps/web-foldkit/src/page/out-message.ts +++ b/apps/web-foldkit/src/page/out-message.ts @@ -6,6 +6,7 @@ import { ModalRequest } from "../overlay/modal/requests" import { AppRoute } from "../route" import { ToastRequest } from "../overlay/toasts" import { SoundSettings } from "../notification-sound" +import { Overrides } from "~/lib/keybindings/keymap" import { ThemePreference } from "../theme" /** Facts a page (or the shell) reports to the root, which owns navigation and the overlays. */ @@ -34,6 +35,8 @@ export const PageOutMessage = defineMessageUnion({ RequestedTheme: { preference: ThemePreference }, /** Store the notification sound settings (legacy `setSettings`); read them back as `Shared.soundSettings`. */ RequestedSoundSettings: { settings: SoundSettings }, + /** Apply and store the keybinding overrides; read them back as `Shared.keys` / `keybindingOverrides`. */ + RequestedKeybindings: { overrides: Overrides }, /** Re-run `user.me` (legacy `useAtomRefresh(userAtom)`), with the toast that goes with it. */ RequestedCurrentUserRefresh: { toast: Schema.optionalKey(ToastRequest) }, /** Desktop: check for an app update now; read the result back as `Shared.desktopUpdate`. */ diff --git a/apps/web-foldkit/src/page/registry.ts b/apps/web-foldkit/src/page/registry.ts index e5b55198f..55ef3168b 100644 --- a/apps/web-foldkit/src/page/registry.ts +++ b/apps/web-foldkit/src/page/registry.ts @@ -28,6 +28,7 @@ import * as OrgHome from "./home" import * as Profile from "./profile" import * as MySettingsAppearance from "./my-settings/appearance" import * as MySettingsDesktop from "./my-settings/desktop" +import * as MySettingsKeybinds from "./my-settings/keybinds" import * as MySettingsLinkedAccounts from "./my-settings/linked-accounts" import * as MySettingsNotifications from "./my-settings/notifications" import * as MySettingsProfile from "./my-settings/profile" @@ -91,6 +92,7 @@ export const pages = [ SettingsChatSyncConnection.page, MySettingsAppearance.page, MySettingsDesktop.page, + MySettingsKeybinds.page, MySettingsLinkedAccounts.page, MySettingsNotifications.page, MySettingsProfile.page, diff --git a/apps/web-foldkit/src/page/test-shared.ts b/apps/web-foldkit/src/page/test-shared.ts index 163628c0f..7cb3a01c0 100644 --- a/apps/web-foldkit/src/page/test-shared.ts +++ b/apps/web-foldkit/src/page/test-shared.ts @@ -1,5 +1,6 @@ import { DEFAULT_SOUND_SETTINGS } from "../notification-sound" import { defaultThemePreference } from "../theme" +import { appKeymap } from "~/lib/keybindings/app-keymap" import type { Shared } from "./contract" /** The root-provided `Shared` fields tests rarely care about; spread into a test's `Shared` literal. */ @@ -7,6 +8,9 @@ export const sharedDefaults = { isMobile: false, theme: { ...defaultThemePreference(), resolved: "light" }, soundSettings: DEFAULT_SOUND_SETTINGS, + keys: appKeymap.defaults, + keybindingOverrides: {}, + keyboardPlatform: "linux", desktopUpdate: { _tag: "Idle" }, isOverlayOpen: false, } satisfies Partial diff --git a/apps/web-foldkit/src/route.test.ts b/apps/web-foldkit/src/route.test.ts index 9dbc6434d..332c0c0c3 100644 --- a/apps/web-foldkit/src/route.test.ts +++ b/apps/web-foldkit/src/route.test.ts @@ -91,6 +91,10 @@ describe("urlToAppRoute", () => { expect(parse(path)._tag).toBe(tag) }) + it("resolves the routes added since the port", () => { + expect(parse("/hazel/my-settings/keybinds")._tag).toBe("MySettingsKeybinds") + }) + it("decodes branded and query segments", () => { expect(parse(`/hazel/chat/${channelId}`)).toMatchObject({ orgSlug: "hazel", channelId }) expect(parse(`/hazel/profile/${userId}`)).toMatchObject({ userId }) @@ -385,6 +389,7 @@ describe("hrefOf", () => { [AppRoute.MySettingsNotifications({ orgSlug }), "/hazel/my-settings/notifications"], ], MySettingsDesktop: [[AppRoute.MySettingsDesktop({ orgSlug }), "/hazel/my-settings/desktop"]], + MySettingsKeybinds: [[AppRoute.MySettingsKeybinds({ orgSlug }), "/hazel/my-settings/keybinds"]], NotificationsAll: [[AppRoute.NotificationsAll({ orgSlug }), "/hazel/notifications"]], NotificationsGeneral: [[AppRoute.NotificationsGeneral({ orgSlug }), "/hazel/notifications/general"]], NotificationsThreads: [[AppRoute.NotificationsThreads({ orgSlug }), "/hazel/notifications/threads"]], diff --git a/apps/web-foldkit/src/route.ts b/apps/web-foldkit/src/route.ts index 8fe74e063..3dc31fcf5 100644 --- a/apps/web-foldkit/src/route.ts +++ b/apps/web-foldkit/src/route.ts @@ -60,6 +60,7 @@ export const AppRoute = defineRouteUnion({ }, MySettingsNotifications: { orgSlug: Schema.String }, MySettingsDesktop: { orgSlug: Schema.String }, + MySettingsKeybinds: { orgSlug: Schema.String }, NotificationsAll: { orgSlug: Schema.String }, NotificationsGeneral: { orgSlug: Schema.String }, NotificationsThreads: { orgSlug: Schema.String }, @@ -227,6 +228,7 @@ const routers = { Route.mapTo(AppRoute.MySettingsNotifications), ), MySettingsDesktop: pipe(orgPath("my-settings", "desktop"), Route.mapTo(AppRoute.MySettingsDesktop)), + MySettingsKeybinds: pipe(orgPath("my-settings", "keybinds"), Route.mapTo(AppRoute.MySettingsKeybinds)), NotificationsAll: pipe(orgPath("notifications"), Route.mapTo(AppRoute.NotificationsAll)), NotificationsGeneral: pipe( orgPath("notifications", "general"), @@ -357,6 +359,7 @@ export const urlToAppRoute = Route.parseUrlWithFallback( routers.MySettingsLinkedAccounts, routers.MySettingsNotifications, routers.MySettingsDesktop, + routers.MySettingsKeybinds, routers.NotificationsAll, routers.NotificationsGeneral, routers.NotificationsThreads, @@ -452,6 +455,7 @@ export const isMySettingsRoute = AppRoute.isAnyOf([ "MySettingsLinkedAccounts", "MySettingsNotifications", "MySettingsDesktop", + "MySettingsKeybinds", ]) /** `/$orgSlug/notifications/*`. */ diff --git a/apps/web-foldkit/src/shell/channels-sidebar.ts b/apps/web-foldkit/src/shell/channels-sidebar.ts index 1cafbd3f4..30c324ebf 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar.ts @@ -217,6 +217,8 @@ type ShellArgs = readonly [ email: string | undefined, avatarUrl: string | null | undefined, appVersion: string, + paletteKey: string | null, + createChannelKey: string | null, ] const sidebarBody = ( @@ -234,7 +236,18 @@ const sidebarBody = ( statusExpiration: string | null, toUserMenuMessage: (message: Menu.Message) => ParentMessage, toOrgSwitcherMessage: (message: Menu.Message) => ParentMessage, - ...[orgSlug, route, orgName, orgLogoUrl, displayName, email, avatarUrl, appVersion]: ShellArgs + ...[ + orgSlug, + route, + orgName, + orgLogoUrl, + displayName, + email, + avatarUrl, + appVersion, + paletteKey, + createChannelKey, + ]: ShellArgs ): Html => { const shell: ShellContext = { orgSlug, @@ -245,6 +258,8 @@ const sidebarBody = ( ? undefined : { displayName, email, avatarUrl: avatarUrl ?? null }, appVersion, + paletteKey, + createChannelKey, } return sidebarStatic(h, "flex flex-1", [ orgSwitcherHeader(h, orgSwitcher, orgSwitcherEntries, shell, organizations, toOrgSwitcherMessage), @@ -256,6 +271,8 @@ const sidebarBody = ( route, activeChannelId, nowMs, + paletteKey, + createChannelKey, onActiveMount: h.OnMount(Mount.mapMessage(ScrollActiveIntoView(), toParentMessage)), onBrowseChannels: h.OnClick(toParentMessage(Message.ClickedBrowseChannels())), onDismissCreateChannelHint: h.OnClick( @@ -328,5 +345,7 @@ export const view = ( shell.currentUser?.email, shell.currentUser?.avatarUrl, shell.appVersion, + shell.paletteKey, + shell.createChannelKey, ]) } diff --git a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts index 85c6cfad8..364717dcc 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/create-channel-hint.ts @@ -6,7 +6,11 @@ import { button } from "../../ui/button" import { hotkeyLabel } from "./hotkey-label" /** `CreateChannelHint`: under the Channels section while it is empty, until dismissed. */ -export const createChannelHint = (h: HtmlBuilder, onDismiss: Attribute): Html => +export const createChannelHint = ( + h: HtmlBuilder, + onDismiss: Attribute, + createChannelKey: string | null, +): Html => h.div( [ h.Class( @@ -17,17 +21,19 @@ export const createChannelHint = (h: HtmlBuilder, onDismiss: Attribute) IconLightbulb(h, { className: "size-3.5 shrink-0" }), h.span( [h.Class("flex-1")], - [ - "Create your first channel with ", - h.kbd( - [ - h.Attribute("data-slot", "keyboard"), - h.Attribute("dir", "ltr"), - h.Class(twMerge(keyboardStyles, "text-[10px]")), + createChannelKey === null + ? ["Create your first channel with the + next to Channels."] + : [ + "Create your first channel with ", + h.kbd( + [ + h.Attribute("data-slot", "keyboard"), + h.Attribute("dir", "ltr"), + h.Class(twMerge(keyboardStyles, "text-[10px]")), + ], + [hotkeyLabel(createChannelKey)], + ), ], - [hotkeyLabel("Mod+Alt+N")], - ), - ], ), button( h, diff --git a/apps/web-foldkit/src/shell/channels-sidebar/hotkey-label.ts b/apps/web-foldkit/src/shell/channels-sidebar/hotkey-label.ts index a9159ce5c..0c853396a 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/hotkey-label.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/hotkey-label.ts @@ -1,56 +1,4 @@ -/** - * `useAppHotkeyLabel` for default bindings: `@tanstack/hotkeys`' `formatForDisplay` - * (macOS symbols, `Ctrl+K` style elsewhere). User overrides from settings are not read yet. - */ +import { comboLabel, type Platform } from "~/lib/keybindings/key-combo" -type Platform = "mac" | "windows" | "linux" - -const detectPlatform = (): Platform => { - if (typeof navigator === "undefined") return "linux" - const platform = navigator.platform?.toLowerCase() ?? "" - const userAgent = navigator.userAgent?.toLowerCase() ?? "" - if (platform.includes("mac") || userAgent.includes("mac")) return "mac" - if (platform.includes("win") || userAgent.includes("win")) return "windows" - return "linux" -} - -const MODIFIER_ORDER = ["Control", "Alt", "Shift", "Meta"] as const -type Modifier = (typeof MODIFIER_ORDER)[number] - -const MAC_SYMBOLS: Record = { Control: "⌃", Alt: "⌥", Shift: "⇧", Meta: "⌘" } -const STANDARD_LABELS: Record = { Control: "Ctrl", Alt: "Alt", Shift: "Shift", Meta: "Win" } - -const toModifier = (part: string, platform: Platform): Modifier | undefined => { - const lower = part.toLowerCase() - if (lower === "mod") return platform === "mac" ? "Meta" : "Control" - if (lower === "control" || lower === "ctrl") return "Control" - if (lower === "alt" || lower === "option") return "Alt" - if (lower === "shift") return "Shift" - if (lower === "meta" || lower === "cmd" || lower === "command") return "Meta" - return undefined -} - -/** Keys shown as symbols, as `formatForDisplay` does. */ -const KEY_LABELS: Readonly> = { - ArrowUp: "↑", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - Escape: "Esc", - PageUp: "PgUp", - PageDown: "PgDn", - Enter: "Enter", - Backspace: "⌫", - Delete: "Del", -} - -export const hotkeyLabel = (hotkey: string, platform: Platform = detectPlatform()): string => { - const parts = hotkey.split("+") - const modifiers = new Set(parts.flatMap((part) => toModifier(part, platform) ?? [])) - const rawKey = parts.filter((part) => toModifier(part, platform) === undefined).join("+") - const key = KEY_LABELS[rawKey] ?? rawKey - const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.has(modifier)) - return platform === "mac" - ? [...ordered.map((modifier) => MAC_SYMBOLS[modifier]), key].join("") - : [...ordered.map((modifier) => STANDARD_LABELS[modifier]), key].join("+") -} +/** A key combo as one label: `⌘K` on a Mac, `Ctrl+K` elsewhere (`key-combo.ts`). */ +export const hotkeyLabel = (hotkey: string, platform?: Platform): string => comboLabel(hotkey, platform) diff --git a/apps/web-foldkit/src/shell/channels-sidebar/view.ts b/apps/web-foldkit/src/shell/channels-sidebar/view.ts index 3c1ad564b..db467d68b 100644 --- a/apps/web-foldkit/src/shell/channels-sidebar/view.ts +++ b/apps/web-foldkit/src/shell/channels-sidebar/view.ts @@ -41,6 +41,7 @@ interface SectionsContext extends ItemContext { readonly isMembersActive: boolean readonly onActiveMount: Attribute readonly onBrowseChannels: Attribute + readonly paletteKey: string | null /** The row menus' inputs; each row builds its own menu inside its memo. */ readonly openMenu: Model["openMenu"] readonly sections: Model["sections"] @@ -301,19 +302,23 @@ const gotoSection = (h: HtmlBuilder, context: SectionsContext): Html => sidebarItem(h, { attributes: [context.onBrowseChannels] }, [ IconMagnifier3(h), label(h, ["Browse channels"]), - h.kbd( - [ - h.Attribute("data-slot", "keyboard"), - h.Attribute("dir", "ltr"), - h.Class( - twMerge( - "hidden font-mono text-[0.80rem]/6 text-current/60 group-hover:text-fg group-focus:text-fg group-focus:opacity-90 group-disabled:opacity-50 lg:inline forced-colors:group-focus:text-[HighlightText]", - "absolute top-1/2 right-2 -translate-y-1/2 font-mono text-muted-fg text-xs", + ...(context.paletteKey === null + ? [] + : [ + h.kbd( + [ + h.Attribute("data-slot", "keyboard"), + h.Attribute("dir", "ltr"), + h.Class( + twMerge( + "hidden font-mono text-[0.80rem]/6 text-current/60 group-hover:text-fg group-focus:text-fg group-focus:opacity-90 group-disabled:opacity-50 lg:inline forced-colors:group-focus:text-[HighlightText]", + "absolute top-1/2 right-2 -translate-y-1/2 font-mono text-muted-fg text-xs", + ), + ), + ], + [hotkeyLabel(context.paletteKey)], ), - ), - ], - [hotkeyLabel("Mod+K")], - ), + ]), ]), sidebarItem(h, {}, [ sidebarLink( @@ -451,6 +456,8 @@ export const sectionGroupContent = ( readonly onActiveMount: Attribute readonly onBrowseChannels: Attribute readonly onDismissCreateChannelHint: Attribute + readonly paletteKey: string | null + readonly createChannelKey: string | null readonly toParentMessage: (message: Message) => M readonly sectionAction: (sectionKey: string) => Html }, @@ -487,7 +494,13 @@ export const sectionGroupContent = ( ), ...discoverSection(h, model, context), ...(defaultEntries.length === 0 && canCreateChannel && !model.isCreateChannelHintDismissed - ? [createChannelHint(h, itemContext.onDismissCreateChannelHint)] + ? [ + createChannelHint( + h, + itemContext.onDismissCreateChannelHint, + itemContext.createChannelKey, + ), + ] : []), ...model.sections.map((section) => channelSection( diff --git a/apps/web-foldkit/src/shell/context.ts b/apps/web-foldkit/src/shell/context.ts index 0bdd824d4..3acfbe2c4 100644 --- a/apps/web-foldkit/src/shell/context.ts +++ b/apps/web-foldkit/src/shell/context.ts @@ -10,4 +10,7 @@ export interface ShellContext { | { readonly displayName: string; readonly email: string; readonly avatarUrl: string | null } | undefined readonly appVersion: string + /** The user's keys for the sidebar's hints (`null`: unbound, no hint). */ + readonly paletteKey: string | null + readonly createChannelKey: string | null } diff --git a/apps/web-foldkit/src/shell/sidebars.ts b/apps/web-foldkit/src/shell/sidebars.ts index 2af9db666..45f91157f 100644 --- a/apps/web-foldkit/src/shell/sidebars.ts +++ b/apps/web-foldkit/src/shell/sidebars.ts @@ -2,6 +2,7 @@ import type { Attribute, Html, HtmlBuilder } from "foldkit/html" import { IconArrowPath, IconBell, + IconKeyboard, IconCube, IconCode, IconEmojiAdd, @@ -174,6 +175,7 @@ export const mySettingsSidebar = ( const linkedAccounts = linkedAccountsHref(orgSlug) const notifications = hrefOf(AppRoute.MySettingsNotifications({ orgSlug })) const desktop = hrefOf(AppRoute.MySettingsDesktop({ orgSlug })) + const keybinds = hrefOf(AppRoute.MySettingsKeybinds({ orgSlug })) const { route } = context const item = navList(h) return sidebarStatic(h, "flex flex-1", [ @@ -203,6 +205,7 @@ export const mySettingsSidebar = ( onRoutes(route, "MySettingsNotifications"), IconBell, ), + item("Keybinds", keybinds, onRoutes(route, "MySettingsKeybinds"), IconKeyboard), // Desktop settings: the Tauri app's, and the native app's update section. ...(isDesktop || hasUpdater ? [item("Desktop", desktop, onRoutes(route, "MySettingsDesktop"), IconCube)] diff --git a/apps/web-foldkit/src/test/root-fixtures.ts b/apps/web-foldkit/src/test/root-fixtures.ts index e1df9be86..8f545d41b 100644 --- a/apps/web-foldkit/src/test/root-fixtures.ts +++ b/apps/web-foldkit/src/test/root-fixtures.ts @@ -1,3 +1,4 @@ +import type { Overrides } from "~/lib/keybindings/keymap" import { OrganizationId, OrganizationMemberId, UserId } from "@hazel/schema" import { Option, Schema } from "effect" import type { HtmlBuilder } from "foldkit/html" @@ -47,6 +48,7 @@ export interface BootOptions { readonly themePreference?: ThemePreference readonly systemTheme?: "light" | "dark" readonly isMobile?: boolean + readonly keybindingOverrides?: Overrides } /** `init` at `path` with default flags (the Model and its Commands). */ @@ -56,6 +58,8 @@ export const bootReturn = (path: string, options: BootOptions = {}) => themePreference: options.themePreference ?? defaultThemePreference(), systemTheme: options.systemTheme ?? "light", soundSettings: DEFAULT_SOUND_SETTINGS, + keybindingOverrides: options.keybindingOverrides ?? {}, + keyboardPlatform: "linux", sessionStartMs: 0, isMobile: options.isMobile ?? false, isCreateChannelHintDismissed: false, diff --git a/apps/web-foldkit/src/theme.test.ts b/apps/web-foldkit/src/theme.test.ts index 164125bc0..f4b24b6b9 100644 --- a/apps/web-foldkit/src/theme.test.ts +++ b/apps/web-foldkit/src/theme.test.ts @@ -62,6 +62,8 @@ const boot = (themePreference = defaultThemePreference()) => sessionStartMs: 0, isMobile: false, isCreateChannelHintDismissed: false, + keybindingOverrides: {}, + keyboardPlatform: "linux", }, url, ), From b63192a1586241b9548c59f8165fcc9da01c9c34 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:51:12 +0100 Subject: [PATCH 2/6] fix: main's failing lint and desktop scene test The desktop page's launch-at-login switch now reads isDesktop from Shared, so its scene test can render the desktop view without module mocking. Clears the conditional-spread and typeof lint errors in the toaster and the native app. Co-Authored-By: Claude Opus 5.5 --- apps/native/src/desktop-bridge.ts | 2 +- apps/native/src/entry.ts | 13 +++++++++---- apps/web-foldkit/src/app/model.ts | 2 ++ apps/web-foldkit/src/overlay/toaster.ts | 2 +- apps/web-foldkit/src/page/contract.ts | 2 ++ .../src/page/my-settings/desktop/scene.test.ts | 2 +- .../src/page/my-settings/desktop/view.ts | 4 ++-- apps/web-foldkit/src/page/test-shared.ts | 1 + 8 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/native/src/desktop-bridge.ts b/apps/native/src/desktop-bridge.ts index de12e27b4..6065f557e 100644 --- a/apps/native/src/desktop-bridge.ts +++ b/apps/native/src/desktop-bridge.ts @@ -110,7 +110,7 @@ export const makeDesktopBridge = (options: { t: "notify", title: content.title, body: content.body ?? "", - ...(content.group === undefined ? {} : { group: content.group }), + group: content.group, }), } diff --git a/apps/native/src/entry.ts b/apps/native/src/entry.ts index 62594c0a1..2fa0c9888 100644 --- a/apps/native/src/entry.ts +++ b/apps/native/src/entry.ts @@ -7,9 +7,14 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeF import { homedir } from "node:os" import { dirname, join } from "node:path" import { stdioTransport, StyleResolver } from "@hazel/foldkit-rune" +import { Option, Schema } from "effect" import { overlayPlugin } from "./overlay-plugin" import type { NativePlatform } from "./platform" +/** The saved store: an object of strings; a value of another type is dropped, not the whole store. */ +const decodeSaved = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) +const isString = Schema.is(Schema.String) + // Before any app module loads: its overlay helpers become the host-drawn ones. Bun.plugin(overlayPlugin) @@ -65,11 +70,11 @@ const platform: NativePlatform = { storage: { load: () => { try { - const saved: unknown = JSON.parse(readFileSync(storagePath, "utf8")) - if (typeof saved !== "object" || saved === null) return undefined + const saved = decodeSaved(JSON.parse(readFileSync(storagePath, "utf8"))) + if (Option.isNone(saved)) return undefined return Object.fromEntries( - Object.entries(saved).filter( - (entry): entry is [string, string] => typeof entry[1] === "string", + Object.entries(saved.value).filter((entry): entry is [string, string] => + isString(entry[1]), ), ) } catch { diff --git a/apps/web-foldkit/src/app/model.ts b/apps/web-foldkit/src/app/model.ts index 39819db56..64b40e5d5 100644 --- a/apps/web-foldkit/src/app/model.ts +++ b/apps/web-foldkit/src/app/model.ts @@ -16,6 +16,7 @@ import { appKeymap } from "~/lib/keybindings/app-keymap" import { Platform as KeyboardPlatform } from "~/lib/keybindings/key-combo" import { Overrides } from "~/lib/keybindings/keymap" import * as Platform from "../platform" +import { isDesktop } from "../platform/desktop" import * as Updates from "../platform/updates" import * as Shell from "../shell/model" import type { Context as ShellUpdateContext } from "../shell/update" @@ -161,6 +162,7 @@ export const sharedOf = (model: Model): Shared => ({ keys: appKeymap.resolve(model.keybindingOverrides, model.keyboardPlatform), keybindingOverrides: model.keybindingOverrides, keyboardPlatform: model.keyboardPlatform, + isDesktop, desktopUpdate: model.desktopUpdate, isOverlayOpen: model.modal.current !== null || model.commandPalette.isOpen, }) diff --git a/apps/web-foldkit/src/overlay/toaster.ts b/apps/web-foldkit/src/overlay/toaster.ts index 2e85ec8e2..b5cc53eb1 100644 --- a/apps/web-foldkit/src/overlay/toaster.ts +++ b/apps/web-foldkit/src/overlay/toaster.ts @@ -86,7 +86,7 @@ export const view = ( viewInputs: { position: isMobile ? "TopCenter" : "BottomRight", // The kit pads the container inline; a phone's keeps clear of the status bar. - ...(isMobile ? { containerClassName: "w-full pt-[calc(env(safe-area-inset-top)+0.5rem)]!" } : {}), + containerClassName: isMobile ? "w-full pt-[calc(env(safe-area-inset-top)+0.5rem)]!" : undefined, entryClassName: isMobile ? `${toastEntryClassName} ${toastEntryFromTopClassName}` : toastEntryClassName, diff --git a/apps/web-foldkit/src/page/contract.ts b/apps/web-foldkit/src/page/contract.ts index 3f402fdda..e28bebc85 100644 --- a/apps/web-foldkit/src/page/contract.ts +++ b/apps/web-foldkit/src/page/contract.ts @@ -37,6 +37,8 @@ export interface Shared { /** The overrides themselves (Keybinds settings): which actions the user changed. */ readonly keybindingOverrides: Overrides readonly keyboardPlatform: Platform + /** Running in the Tauri desktop app (`platform/desktop`), where the desktop-only settings apply. */ + readonly isDesktop: boolean /** The desktop app's update check and install (always `Idle` on the web). */ readonly desktopUpdate: Updates.Model /** A root modal or the command palette is open (it holds the keyboard; the composer's global typing waits). */ diff --git a/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts b/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts index f5d394075..0015b1b9a 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/scene.test.ts @@ -9,7 +9,7 @@ import { view } from "./view" /** The desktop page through its view: the launch-at-login switch waits for the real autostart state. */ -const shared = makeShared() +const shared = makeShared({ isDesktop: true }) const openAtLogin = Scene.label("Open at login") describe("launch at startup", () => { diff --git a/apps/web-foldkit/src/page/my-settings/desktop/view.ts b/apps/web-foldkit/src/page/my-settings/desktop/view.ts index 2c8d97aa7..3256f0bb0 100644 --- a/apps/web-foldkit/src/page/my-settings/desktop/view.ts +++ b/apps/web-foldkit/src/page/my-settings/desktop/view.ts @@ -1,7 +1,7 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { IconCheck, IconCube, IconDownload } from "../../../icons" -import { hasUpdater, isDesktop } from "../../../platform/desktop" +import { hasUpdater } from "../../../platform/desktop" import * as Updates from "../../../platform/updates" import { badge } from "../../../ui/badge" import { button } from "../../../ui/button" @@ -246,7 +246,7 @@ export const view = Submodel.defineView((model, pageHeader(h, "Desktop", "Settings for the desktop application."), // Autostart is a Tauri plugin: the native app (an updater without Tauri) has none yet, // and a browser that opens this page directly has neither. - ...(isDesktop + ...(shared.isDesktop ? [ h.div( [h.Class("flex flex-col gap-5")], diff --git a/apps/web-foldkit/src/page/test-shared.ts b/apps/web-foldkit/src/page/test-shared.ts index 7cb3a01c0..f797fdcd4 100644 --- a/apps/web-foldkit/src/page/test-shared.ts +++ b/apps/web-foldkit/src/page/test-shared.ts @@ -11,6 +11,7 @@ export const sharedDefaults = { keys: appKeymap.defaults, keybindingOverrides: {}, keyboardPlatform: "linux", + isDesktop: false, desktopUpdate: { _tag: "Idle" }, isOverlayOpen: false, } satisfies Partial From 4adc0ae36592ea6fb35664fcefd7833577a427ff Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:51:12 +0100 Subject: [PATCH 3/6] fix(web): keybinds search by key name, safer recording, review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search matches the names people type for keys: cmd, ctrl, option, shift, esc. - The recorder refuses keys that type (J, Shift+K, Tab, Mac Option+letter), keeps listening, and clean drops them from storage. - Cmd+Ctrl+K records as Control+Meta+K; ß keeps its spelling. - Stored overrides decode entry by entry. - Reset takes the default key back from the action holding it, and says so. - A moved key that also shadows a built-in reports both. - Lint: no casts or open dictionaries in the keymap. Co-Authored-By: Claude Opus 5.5 --- .../core/lib/keybindings/key-combo.test.ts | 67 +++++++- .../src/core/lib/keybindings/key-combo.ts | 148 ++++++++++++++---- .../src/core/lib/keybindings/keymap.test.ts | 34 +++- .../src/core/lib/keybindings/keymap.ts | 90 +++++++---- .../page/my-settings/keybinds/scene.test.ts | 96 ++++++++++++ .../src/page/my-settings/keybinds/update.ts | 55 +++++-- .../src/page/my-settings/keybinds/view.ts | 37 +++-- 7 files changed, 440 insertions(+), 87 deletions(-) diff --git a/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts b/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts index 91684f678..8ff172e58 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/key-combo.test.ts @@ -1,6 +1,16 @@ import { Option, Schema } from "effect" import { describe, expect, test } from "vitest" -import { combo, comboLabel, comboOfPress, identityOf, KeyCombo, keyCaps, normalizeCombo } from "./key-combo" +import { + combo, + comboLabel, + comboOfPress, + comboSearchWords, + identityOf, + isTypingSafe, + KeyCombo, + keyCaps, + normalizeCombo, +} from "./key-combo" const press = ( key: string, @@ -54,6 +64,20 @@ describe("comboOfPress", () => { expect(comboOfPress(press("k", { metaKey: true }), "windows")).toEqual(Option.some("Meta+K")) }) + test("both primary keys held are named, so the combo stays valid", () => { + for (const platform of ["mac", "windows"] as const) { + const recorded = comboOfPress(press("k", { metaKey: true, ctrlKey: true }), platform) + expect(recorded).toEqual(Option.some("Control+Meta+K")) + expect(Schema.is(KeyCombo)(Option.getOrThrow(recorded))).toBe(true) + } + }) + + test("a letter whose upper case is two letters keeps its own spelling", () => { + const recorded = comboOfPress(press("ß", { altKey: true }), "linux") + expect(recorded).toEqual(Option.some("Alt+ß")) + expect(Schema.is(KeyCombo)(Option.getOrThrow(recorded))).toBe(true) + }) + test("a modifier alone, or a key no binding can match, makes no combo", () => { expect(comboOfPress(press("Shift", { shiftKey: true }), "mac")).toEqual(Option.none()) expect(comboOfPress(press("Meta", { metaKey: true }), "mac")).toEqual(Option.none()) @@ -84,3 +108,44 @@ describe("labels", () => { expect(comboLabel("Escape", "linux")).toBe("Esc") }) }) + +describe("comboSearchWords", () => { + test("names each key the way the platform does", () => { + expect(comboSearchWords("Mod+Alt+ArrowUp", "mac")).toEqual( + expect.arrayContaining(["⌘", "cmd", "command", "option", "alt", "up", "arrowup"]), + ) + expect(comboSearchWords("Mod+Shift+K", "windows")).toEqual( + expect.arrayContaining(["ctrl", "control", "shift", "k"]), + ) + expect(comboSearchWords("Mod+K", "windows")).not.toContain("cmd") + expect(comboSearchWords("Escape", "linux")).toEqual(expect.arrayContaining(["esc", "escape"])) + }) +}) + +describe("isTypingSafe", () => { + test("a key that types needs ⌘, Ctrl or Win (Alt types on a Mac); keys that type nothing need less", () => { + for (const text of [ + "Mod+K", + "Alt+ArrowUp", + "Control+J", + "Meta+Tab", + "Escape", + "Shift+Escape", + "PageUp", + "F5", + ]) + expect(isTypingSafe(combo(text)), text).toBe(true) + for (const text of [ + "K", + "Shift+K", + "Alt+K", + "Alt+@", + "Alt+Space", + "Tab", + "Shift+Tab", + "ArrowUp", + "/", + ]) + expect(isTypingSafe(combo(text)), text).toBe(false) + }) +}) diff --git a/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts b/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts index c9451d95a..97f579b57 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/key-combo.ts @@ -53,13 +53,9 @@ const UNBINDABLE_KEYS = new Set([ "compose", ]) -const MODIFIER_ALIASES: Readonly> = { - mod: "Mod", - control: "Control", - meta: "Meta", - alt: "Alt", - shift: "Shift", -} +const MODIFIER_ALIASES = new Map( + Object.entries({ mod: "Mod", control: "Control", meta: "Meta", alt: "Alt", shift: "Shift" } as const), +) /** Named keys, keyed by their lower case: the canonical spelling `KeyboardEvent.key` uses. */ const NAMED_KEYS: Readonly> = Object.fromEntries( @@ -91,7 +87,10 @@ const canonicalKey = (raw: string): string | undefined => { if (lower === "" || UNBINDABLE_KEYS.has(lower)) return undefined if (NAMED_KEYS[lower] !== undefined) return NAMED_KEYS[lower] // One printable character (a letter, digit or symbol); longer names are keys we do not know. - return [...raw].length === 1 ? raw.toUpperCase() : undefined + if ([...raw].length !== 1) return undefined + // Upper case, unless that makes it two letters (`ß` is `SS`). + const upper = raw.toUpperCase() + return [...upper].length === 1 ? upper : raw } /** Reads `Mod+Shift+K`; `undefined` for anything Foldkit would reject. */ @@ -103,7 +102,7 @@ export const parseCombo = (text: string): ParsedCombo | undefined => { if (key === undefined) return undefined const modifiers = new Set() for (const part of parts.slice(0, -1)) { - const modifier = MODIFIER_ALIASES[part.trim().toLowerCase()] + const modifier = MODIFIER_ALIASES.get(part.trim().toLowerCase()) if (modifier === undefined || modifiers.has(modifier)) return undefined modifiers.add(modifier) } @@ -158,8 +157,12 @@ export const heldModifiers = (press: KeyPress, platform: Platform): ReadonlySet< const held = new Set() const primary = platform === "mac" ? press.metaKey : press.ctrlKey const secondary = platform === "mac" ? press.ctrlKey : press.metaKey - if (primary) held.add("Mod") - if (secondary) held.add(platform === "mac" ? "Control" : "Meta") + // Both held: name them both, since `Mod` cannot sit beside the key it stands for. + if (primary && secondary) { + held.add("Control") + held.add("Meta") + } else if (primary) held.add("Mod") + else if (secondary) held.add(platform === "mac" ? "Control" : "Meta") if (press.altKey) held.add("Alt") if (press.shiftKey) held.add("Shift") return held @@ -172,32 +175,64 @@ export const comboOfPress = (press: KeyPress, platform: Platform): Option.Option return Option.some(formatParsed({ modifiers: heldModifiers(press, platform), key }) as KeyCombo) } +/** Keys that type nothing, so they cannot steal a character from a text field. */ +const NON_TYPING_KEYS = new Set( + Object.values(NAMED_KEYS).filter((key) => key !== "Space" && key !== "Plus" && key !== "Tab"), +) +/** Keys that may stand alone, with no modifier at all. */ +const STANDALONE_KEYS = new Set([ + "Escape", + "PageUp", + "PageDown", + ...Array.from({ length: 24 }, (_, index) => `F${index + 1}`), +]) + +/** + * Whether a combo is safe to bind app-wide, where it also fires in the composer and takes the + * keystroke from it. It needs ⌘, Ctrl or Win; Alt only with a key that types nothing (⌥ types `@` or + * `ø` on a Mac, AltGr does elsewhere); and only Escape, PageUp/PageDown and F-keys may stand alone. + * `K`, `Shift+K`, `Alt+K`, `Tab` and `ArrowUp` are not safe. + */ +export const isTypingSafe = (keyCombo: KeyCombo): boolean => { + const parsed = parseCombo(keyCombo) + if (parsed === undefined) return false + const { modifiers, key } = parsed + if (modifiers.has("Mod") || modifiers.has("Control") || modifiers.has("Meta")) return true + if (modifiers.has("Alt")) return NON_TYPING_KEYS.has(key) + return STANDALONE_KEYS.has(key) +} + // LABELS -const MAC_SYMBOLS: Record = { Mod: "⌘", Control: "⌃", Meta: "⌘", Alt: "⌥", Shift: "⇧" } -const STANDARD_LABELS: Record = { +const MAC_SYMBOLS = { Mod: "⌘", Control: "⌃", Meta: "⌘", Alt: "⌥", Shift: "⇧" } satisfies Record< + Modifier, + string +> +const STANDARD_LABELS = { Mod: "Ctrl", Control: "Ctrl", Meta: "Win", Alt: "Alt", Shift: "Shift", -} +} satisfies Record /** Display order: the platform's own (⌃⌥⇧⌘ on a Mac, Ctrl+Alt+Shift elsewhere). */ const MAC_DISPLAY_ORDER: ReadonlyArray = ["Control", "Alt", "Shift", "Mod", "Meta"] const STANDARD_DISPLAY_ORDER: ReadonlyArray = ["Mod", "Control", "Alt", "Shift", "Meta"] -const KEY_LABELS: Readonly> = { - ArrowUp: "↑", - ArrowDown: "↓", - ArrowLeft: "←", - ArrowRight: "→", - Escape: "Esc", - PageUp: "PgUp", - PageDown: "PgDn", - Backspace: "⌫", - Delete: "Del", - Plus: "+", -} +const KEY_LABELS = new Map( + Object.entries({ + ArrowUp: "↑", + ArrowDown: "↓", + ArrowLeft: "←", + ArrowRight: "→", + Escape: "Esc", + PageUp: "PgUp", + PageDown: "PgDn", + Backspace: "⌫", + Delete: "Del", + Plus: "+", + }), +) const modifierLabel = (modifier: Modifier, platform: Platform) => platform === "mac" ? MAC_SYMBOLS[modifier] : STANDARD_LABELS[modifier] @@ -212,9 +247,68 @@ export const modifierCaps = (modifiers: ReadonlySet, platform: Platfor export const keyCaps = (text: string, platform: Platform = detectPlatform()): ReadonlyArray => { const parsed = parseCombo(text) if (parsed === undefined) return [text] - return [...modifierCaps(parsed.modifiers, platform), KEY_LABELS[parsed.key] ?? parsed.key] + return [...modifierCaps(parsed.modifiers, platform), KEY_LABELS.get(parsed.key) ?? parsed.key] } /** The whole combo as one label: `⌘⇧K` on a Mac, `Ctrl+Shift+K` elsewhere. */ export const comboLabel = (text: string, platform: Platform = detectPlatform()): string => keyCaps(text, platform).join(platform === "mac" ? "" : "+") + +// SEARCH + +const MODIFIER_WORDS = { + mac: { + Mod: ["cmd", "command", "mod"], + Control: ["ctrl", "control"], + Meta: ["cmd", "command"], + Alt: ["option", "opt", "alt"], + Shift: ["shift"], + }, + windows: { + Mod: ["ctrl", "control", "mod"], + Control: ["ctrl", "control"], + Meta: ["win", "windows", "meta"], + Alt: ["alt"], + Shift: ["shift"], + }, + linux: { + Mod: ["ctrl", "control", "mod"], + Control: ["ctrl", "control"], + Meta: ["super", "meta"], + Alt: ["alt"], + Shift: ["shift"], + }, +} satisfies Record>> + +const KEY_WORDS = new Map( + Object.entries({ + ArrowUp: ["up", "arrow"], + ArrowDown: ["down", "arrow"], + ArrowLeft: ["left", "arrow"], + ArrowRight: ["right", "arrow"], + Escape: ["esc", "escape"], + PageUp: ["pgup", "page", "up"], + PageDown: ["pgdn", "page", "down"], + Enter: ["enter", "return"], + Backspace: ["backspace"], + Delete: ["del", "delete"], + "/": ["slash", "/"], + Plus: ["plus", "+"], + }), +) + +/** + * The words a combo answers to in a search, lower case: the key caps, and the names people type for + * them (`cmd`, `option` on a Mac; `ctrl`, `alt` elsewhere; `esc`, `up`). + */ +export const comboSearchWords = (text: string, platform: Platform): ReadonlyArray => { + const parsed = parseCombo(text) + if (parsed === undefined) return [text.toLowerCase()] + const words = [ + ...keyCaps(text, platform), + ...[...parsed.modifiers].flatMap((modifier) => MODIFIER_WORDS[platform][modifier]), + parsed.key, + ...(KEY_WORDS.get(parsed.key) ?? []), + ] + return [...new Set(words.map((word) => word.toLowerCase()))] +} diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts index 501449f96..4ff01ac02 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest" import { appKeymap } from "./app-keymap" -import { combo, identityOf, type Platform } from "./key-combo" +import { combo, identityOf, isTypingSafe, type Platform } from "./key-combo" import { defineKeymap } from "./keymap" const keymap = defineKeymap({ @@ -50,15 +50,32 @@ describe("defineKeymap", () => { }) test("reset and unbind", () => { - const overrides = { open: combo("Mod+P") } - expect(keymap.reset(overrides, "open")).toEqual({}) + expect(keymap.reset({ open: combo("Mod+P") }, "open", "linux")).toEqual({ + overrides: {}, + displaced: [], + }) expect(keymap.unbind({}, "open")).toEqual({ open: null }) }) - test("clean drops unknown actions, fixed actions and keys that no longer parse", () => { + test("reset takes the default key back from the action that was given it", () => { + const moved = keymap.assign({}, "search", combo("Mod+K"), "linux").overrides + const { overrides, displaced } = keymap.reset(moved, "open", "linux") + expect(displaced).toEqual(["search"]) + expect(keymap.resolve(overrides, "linux")).toMatchObject({ open: "Mod+K", search: null }) + }) + + test("clean drops unknown actions, fixed actions, and values that are not a safe key", () => { expect( - keymap.clean({ open: "Mod+P", removed: "Mod+J", bold: "Mod+J", search: "Mod+", inbox: null }), + keymap.clean({ + open: "Mod+P", + removed: "Mod+J", + bold: "Mod+J", + search: "Mod+", + inbox: null, + }), ).toEqual({ open: "Mod+P", inbox: null }) + // One bad entry (a number, a bare letter) leaves the rest. + expect(keymap.clean({ open: 42, search: "J", inbox: "Mod+J" })).toEqual({ inbox: "Mod+J" }) }) test("bindings skip unbound actions", () => { @@ -85,4 +102,11 @@ describe("appKeymap", () => { test("with no overrides every action keeps its default", () => { expect(appKeymap.resolve({}, "mac")).toEqual(appKeymap.defaults) }) + + test("every rebindable default is safe to bind, so Reset never restores a key the recorder refuses", () => { + for (const action of appKeymap.actions) { + if (action.rebindable && action.defaultKey !== null) + expect(isTypingSafe(action.defaultKey), action.id).toBe(true) + } + }) }) diff --git a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts index d5e508e79..feb07463f 100644 --- a/apps/web-foldkit/src/core/lib/keybindings/keymap.ts +++ b/apps/web-foldkit/src/core/lib/keybindings/keymap.ts @@ -1,6 +1,6 @@ import { Option, Schema } from "effect" import type { Dom } from "foldkit" -import { combo, identityOf, KeyCombo, type Platform } from "./key-combo" +import { combo, identityOf, isTypingSafe, KeyCombo, type Platform } from "./key-combo" /** * A keymap: every action a key can trigger, declared once, with its default key, where it shows in @@ -17,8 +17,8 @@ import { combo, identityOf, KeyCombo, type Platform } from "./key-combo" * ``` * * The user's choices are `Overrides`: an action's own key, or `null` to unbind it. They are stored as - * loose JSON and cleaned on load, so an action that was renamed or removed, or a key that no longer - * parses, falls back to the default instead of losing every other override. + * loose JSON and cleaned entry by entry on load, so an action that was renamed or removed, or a key + * that no longer parses, falls back to the default instead of losing every other override. */ export interface ActionSpec { @@ -48,7 +48,7 @@ export const Overrides = Schema.Record(Schema.String, Schema.NullOr(KeyCombo)) export type Overrides = typeof Overrides.Type /** What storage holds: the same shape, unchecked, so one bad entry cannot void the rest. */ -export const StoredOverrides = Schema.Record(Schema.String, Schema.NullOr(Schema.String)) +export const StoredOverrides = Schema.Record(Schema.String, Schema.Unknown) /** The key each action has now. */ export type Keys = Readonly> @@ -62,6 +62,13 @@ export interface Assignment { readonly shadows: ReadonlyArray } +/** A reset: the overrides, and the actions that gave the default key back. */ +export interface Reset { + readonly overrides: Overrides + /** Rebindable actions that had taken this action's default key, now unbound. */ + readonly displaced: ReadonlyArray +} + /** How a subscription binds one of its actions; the keys come from the keymap. */ export interface BindingOptions { readonly mapEvent: (event: KeyboardEvent) => Output @@ -76,9 +83,10 @@ export const defineKeymap = type Category = Specs[Id]["category"] + // SAFETY: `Specs` is a literal object, so its own keys are exactly `Id`. const ids = Object.keys(specs) as Array const actions: ReadonlyArray> = ids.map((id) => { - const spec = specs[id]! + const spec: ActionSpec = specs[id] return { id, label: spec.label, @@ -93,20 +101,25 @@ export const defineKeymap = > > const isActionId = (value: string): value is Id => Object.hasOwn(byId, value) - const ActionId = Schema.Literals(ids as unknown as readonly [Id, ...Array]) + const ActionId = Schema.Literals(ids) const defaults = Object.fromEntries(actions.map((action) => [action.id, action.defaultKey])) as Keys - /** Drops overrides for unknown actions, fixed actions, and keys that no longer parse. */ - const clean = (stored: Readonly>): Overrides => { - const cleaned: Record = {} - for (const [id, value] of Object.entries(stored)) { - if (!isActionId(id) || !byId[id].rebindable) continue - if (value === null) cleaned[id] = null - else Option.map(decodeCombo(value), (key) => (cleaned[id] = key)) - } - return cleaned - } + /** + * Drops overrides for unknown actions and fixed ones, and values that are not a key that parses + * and is safe to bind (`isTypingSafe`). + */ + const clean = (stored: Readonly>): Overrides => + Object.fromEntries( + Object.entries(stored).flatMap(([id, value]): ReadonlyArray => { + if (!isActionId(id) || !byId[id].rebindable) return [] + if (value === null) return [[id, null]] + return Option.match(decodeCombo(value), { + onNone: () => [], + onSome: (key) => (isTypingSafe(key) ? [[id, key]] : []), + }) + }), + ) const isOverridden = (overrides: Overrides, id: Id): boolean => Object.hasOwn(overrides, id) @@ -116,21 +129,22 @@ export const defineKeymap = => { - const keys: Record = { ...defaults } + // Overrides first, in keymap order: a later one that repeats a claimed key is unbound. const claimed = new Set() + const overridden = new Map() for (const action of actions) { if (!action.rebindable || !isOverridden(overrides, action.id)) continue const key = overrides[action.id] ?? null const identity = key === null ? null : identityOf(key, platform) - keys[action.id] = identity !== null && claimed.has(identity) ? null : key + overridden.set(action.id, identity !== null && claimed.has(identity) ? null : key) if (identity !== null) claimed.add(identity) } - for (const action of actions) { - if (!action.rebindable || isOverridden(overrides, action.id) || action.defaultKey === null) - continue - if (claimed.has(identityOf(action.defaultKey, platform))) keys[action.id] = null + const keyOf = (action: Action): KeyCombo | null => { + if (overridden.has(action.id)) return overridden.get(action.id) ?? null + const key = action.defaultKey + return action.rebindable && key !== null && claimed.has(identityOf(key, platform)) ? null : key } - return keys as Keys + return Object.fromEntries(actions.map((action) => [action.id, keyOf(action)])) as Keys } /** Sets `id`'s key, unbinding the rebindable actions that had it. */ @@ -148,7 +162,7 @@ export const defineKeymap = sameKey(other)), ) .map((action) => action.id) - const next: Record = { ...overrides } + const next = { ...overrides } for (const other of displaced) next[other] = null // Back on its default, an action needs no override. if (byId[id].defaultKey !== null && sameKey(byId[id].defaultKey)) delete next[id] @@ -156,16 +170,32 @@ export const defineKeymap = - byId[id].defaultKey === null ? reset(overrides, id) : { ...overrides, [id]: null } - - /** Back to `id`'s default key. Another action using that key now keeps it; `id` stays unbound then. */ - const reset = (overrides: Overrides, id: Id): Overrides => { + const withoutOverride = (overrides: Overrides, id: Id): Overrides => { const { [id]: _removed, ...rest } = overrides return rest } + /** Leaves `id` with no key. */ + const unbind = (overrides: Overrides, id: Id): Overrides => + byId[id].defaultKey === null ? withoutOverride(overrides, id) : { ...overrides, [id]: null } + + /** Back to `id`'s default key, taken back from any action that was given it. */ + const reset = (overrides: Overrides, id: Id, platform: Platform): Reset => { + const rest = withoutOverride(overrides, id) + const defaultKey = byId[id].defaultKey + if (defaultKey === null) return { overrides: rest, displaced: [] } + const identity = identityOf(defaultKey, platform) + const displaced = actions + .filter((action) => { + const key = action.rebindable && action.id !== id ? (rest[action.id] ?? null) : null + return key !== null && identityOf(key, platform) === identity + }) + .map((action) => action.id) + const next = { ...rest } + for (const other of displaced) next[other] = null + return { overrides: next, displaced } + } + /** Foldkit bindings for the actions a subscription owns; unbound actions are left out. */ const bindings = ( keys: Keys, diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts index 9868e143f..ae7af2b4a 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts @@ -19,6 +19,9 @@ const sharedWith = (overrides: Overrides) => keyboardPlatform: "linux", }) +const REFUSED = + "Hold Ctrl too: on its own, or with Alt or Shift, this key types, so it would fire while you type." + const keyButton = (label: string) => Scene.role("button", { name: new RegExp(`^${label}:`) }) describe("recording a key", () => { @@ -76,6 +79,54 @@ describe("recording a key", () => { }) }) + test("a key on its own is refused, and recording goes on so a modifier can be added", () => { + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given(init().model), + Scene.click(keyButton("Search Messages")), + Scene.Subscription.emit(Message.PressedCombo({ combo: combo("J") })), + Scene.expectNoOutMessage(), + Scene.expect(Scene.text(REFUSED)).toExist(), + Scene.expect(Scene.text("Press a shortcut")).toExist(), + Scene.Subscription.emit(Message.PressedCombo({ combo: combo("Mod+J") })), + Scene.expectOutMessage( + PageOutMessage.RequestedKeybindings({ overrides: { "search.open": combo("Mod+J") } }), + ), + ) + }) + + test("a refused key keeps its held modifiers on show, and cancelling clears the warning", () => { + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given(init().model), + Scene.click(keyButton("Search Messages")), + Scene.Subscription.emit(Message.PressedCombo({ combo: combo("Shift+K") })), + Scene.expect(Scene.text("Now a key")).toExist(), + Scene.expect(Scene.text(REFUSED)).toExist(), + Scene.Subscription.emit(Message.CancelledRecording()), + Scene.expect(Scene.text(REFUSED)).toBeAbsent(), + Scene.expectNoOutMessage(), + ) + }) + + test("a key taken from an action and shared with a built-in says both", () => { + const recording = update( + init().model, + Message.ClickedRecord({ actionId: "search.open" }), + sharedWith({}), + ) + const result = update( + recording.model, + Message.PressedCombo({ combo: combo("Mod+I") }), + sharedWith({}), + ) + expect(result.model.notice).toEqual({ + actionId: "search.open", + tone: "warning", + text: "Ctrl+I was removed from “Open Inbox”. It is also “Italic”, so it may not work there.", + }) + }) + test("Escape (or a click elsewhere) cancels without saving", () => { const recording = update( init().model, @@ -108,6 +159,23 @@ describe("reset and remove", () => { ) }) + test("Reset takes the default key back, and says from where", () => { + const moved = { "search.open": combo("Mod+I") } + const result = update( + init().model, + Message.ClickedReset({ actionId: "inbox.open" }), + sharedWith(moved), + ) + expect(result.outMessage).toEqual( + PageOutMessage.RequestedKeybindings({ overrides: { "search.open": null } }), + ) + expect(result.model.notice).toEqual({ + actionId: "inbox.open", + tone: "info", + text: "Ctrl+I was removed from “Search Messages”.", + }) + }) + test("Remove keybind leaves the action unbound", () => { expect( update(init().model, Message.ClickedUnbind({ actionId: "channel.pins" }), sharedWith({})) @@ -148,4 +216,32 @@ describe("search", () => { Scene.expect(keyButton("Open Inbox")).toBeAbsent(), ) }) + + test("finds keys by the names people type for them", () => { + const macShared = makeShared({ + keybindingOverrides: {}, + keys: appKeymap.resolve({}, "mac"), + keyboardPlatform: "mac", + }) + Scene.scene( + pageScene(update, view, macShared), + Scene.given({ ...init().model, query: "cmd k" }), + Scene.expect(keyButton("Open Command Palette")).toExist(), + Scene.expect(keyButton("Open Inbox")).toBeAbsent(), + // ⌘/: "k" starts "Keyboard", but a one-letter word only matches a key. + Scene.expect(keyButton("Keyboard Shortcuts")).toBeAbsent(), + ) + Scene.scene( + pageScene(update, view, macShared), + Scene.given({ ...init().model, query: "mod+i" }), + Scene.expect(keyButton("Open Inbox")).toExist(), + Scene.expect(keyButton("Open Command Palette")).toBeAbsent(), + ) + Scene.scene( + pageScene(update, view, sharedWith({})), + Scene.given({ ...init().model, query: "esc" }), + Scene.expect(keyButton("Mark Channel Read")).toExist(), + Scene.expect(keyButton("Open Inbox")).toBeAbsent(), + ) + }) }) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/update.ts b/apps/web-foldkit/src/page/my-settings/keybinds/update.ts index b607488e2..5ff921f6c 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/update.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/update.ts @@ -1,6 +1,6 @@ import { modifyFields } from "foldkit/struct" import { appKeymap } from "~/lib/keybindings/app-keymap" -import { comboLabel, type KeyCombo } from "~/lib/keybindings/key-combo" +import { comboLabel, isTypingSafe, type KeyCombo, parseCombo } from "~/lib/keybindings/key-combo" import type { Overrides } from "~/lib/keybindings/keymap" import type { PageReturn, Shared } from "../../contract" import { PageOutMessage } from "../../out-message" @@ -23,7 +23,7 @@ const save = (model: Model, overrides: Overrides): Return => ({ const labels = (ids: ReadonlyArray) => ids.map((id) => `“${appKeymap.byId[id].label}”`).join(", ") -/** What a new key took from other actions, or what it may lose to. */ +/** What a new key took from other actions, and what it may lose to. */ const noticeOf = ( actionId: ActionId, key: KeyCombo, @@ -32,20 +32,36 @@ const noticeOf = ( shared: Shared, ): Notice | null => { const label = comboLabel(key, shared.keyboardPlatform) - if (shadows.length > 0) - return { - actionId, - tone: "warning", - text: `${label} is also ${labels(shadows)}, so it may not work there.`, - } - if (displaced.length > 0) - return { actionId, tone: "info", text: `${label} was removed from ${labels(displaced)}.` } - return null + const removed = displaced.length > 0 ? `${label} was removed from ${labels(displaced)}.` : null + if (shadows.length > 0) { + const shadowed = `${removed === null ? label : "It"} is also ${labels(shadows)}, so it may not work there.` + return { actionId, tone: "warning", text: removed === null ? shadowed : `${removed} ${shadowed}` } + } + return removed === null ? null : { actionId, tone: "info", text: removed } } +/** Why a key on its own cannot be bound, in the platform's names for the modifiers. */ +const needsModifierNotice = (actionId: ActionId, shared: Shared): Notice => ({ + actionId, + tone: "warning", + text: + shared.keyboardPlatform === "mac" + ? "Hold ⌘ or ⌃ too: on its own, or with ⌥ or ⇧, this key types, so it would fire while you type." + : "Hold Ctrl too: on its own, or with Alt or Shift, this key types, so it would fire while you type.", +}) + const recorded = (model: Model, key: KeyCombo, shared: Shared): Return => { const actionId = model.recording if (actionId === null) return { model } + // Keep listening, so the next press can add the modifier. + if (!isTypingSafe(key)) + return { + model: modifyFields(model, { + // The modifiers are still down; the subscription sends no repeat to show them again. + held: () => [...(parseCombo(key)?.modifiers ?? [])], + notice: () => needsModifierNotice(actionId, shared), + }), + } const { overrides, displaced, shadows } = appKeymap.assign( shared.keybindingOverrides, actionId, @@ -73,9 +89,20 @@ export const update = (model: Model, message: Message, shared: Shared): Return = ChangedHeldModifiers: ({ modifiers }) => model.recording === null ? { model } : { model: modifyFields(model, { held: () => modifiers }) }, PressedCombo: ({ combo }) => recorded(model, combo, shared), - CancelledRecording: () => ({ model: stopped(model, model.notice) }), - ClickedReset: ({ actionId }) => - save(stopped(model, null), appKeymap.reset(shared.keybindingOverrides, actionId)), + // While recording, the only notice is a refused key's, which means nothing once it stops. + CancelledRecording: () => ({ model: stopped(model, model.recording === null ? model.notice : null) }), + ClickedReset: ({ actionId }) => { + const { overrides, displaced } = appKeymap.reset( + shared.keybindingOverrides, + actionId, + shared.keyboardPlatform, + ) + const key = appKeymap.byId[actionId].defaultKey + return save( + stopped(model, key === null ? null : noticeOf(actionId, key, displaced, [], shared)), + overrides, + ) + }, ClickedUnbind: ({ actionId }) => save(stopped(model, null), appKeymap.unbind(shared.keybindingOverrides, actionId)), ClickedResetAll: () => save(stopped(model, null), {}), diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts index e739228e2..3bce0b3e7 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts @@ -2,7 +2,14 @@ import { Submodel } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" import { twJoin } from "tailwind-merge" import { appKeymap, KEYMAP_CATEGORIES } from "~/lib/keybindings/app-keymap" -import { comboLabel, keyCaps, type KeyCombo, modifierCaps, type Platform } from "~/lib/keybindings/key-combo" +import { + comboLabel, + comboSearchWords, + keyCaps, + type KeyCombo, + modifierCaps, + type Platform, +} from "~/lib/keybindings/key-combo" import { IconArrowPath, IconClose, IconLock, IconWarning } from "../../../icons" import { badge } from "../../../ui/badge" import { ariaButton, button } from "../../../ui/button" @@ -42,19 +49,29 @@ const caps = (h: H, key: KeyCombo, platform: Platform): Html => // MATCHING +/** + * The query as typed appears in the row's text or key label (`⌘K`, `Ctrl+K`), or each of its words + * (split on spaces and `+`) starts a word of the row or a name of its keys: `cmd k`, `ctrl+shift`, + * `esc`. A one-letter word only matches a key, so the `k` of `cmd k` is not the start of "keyboard". + */ const matches = (action: AppAction, query: string, shared: Shared): boolean => { const needle = query.trim().toLowerCase() if (needle === "") return true + const platform = shared.keyboardPlatform const keys = appKeymap.keysOf(shared.keys, action.id) - return [ - action.label, - action.description, - action.category, - ...keys.map((key) => comboLabel(key, shared.keyboardPlatform)), - ] - .join(" ") - .toLowerCase() - .includes(needle) + const text = [action.label, action.description, action.category].join(" ").toLowerCase() + const labels = keys.map((key) => comboLabel(key, platform).toLowerCase()) + if (text.includes(needle) || labels.some((label) => label.includes(needle))) return true + const textWords = text.split(/\s+/) + const keyWords = keys.flatMap((key) => comboSearchWords(key, platform)) + return needle + .split(/[\s+]+/) + .filter((token) => token !== "") + .every((token) => + [...token].length === 1 + ? keyWords.includes(token) + : [...textWords, ...keyWords].some((word) => word.startsWith(token)), + ) } // ROW PARTS From 6c7aa75123282dd3e07568a1f1cfbb928c363a75 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 13:58:55 +0100 Subject: [PATCH 4/6] chore(web): drop @tanstack/hotkeys, unused since the keymap replaced the registry Co-Authored-By: Claude Opus 5.5 --- apps/web-foldkit/package.json | 1 - bun.lock | 3 --- 2 files changed, 4 deletions(-) diff --git a/apps/web-foldkit/package.json b/apps/web-foldkit/package.json index 65254eea5..8248ccaf3 100644 --- a/apps/web-foldkit/package.json +++ b/apps/web-foldkit/package.json @@ -23,7 +23,6 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/browser-db-sqlite-persistence": "1.0.0-rc.1.1", "@tanstack/db": "1.0.0-rc.1.1", - "@tanstack/hotkeys": "0.22.0", "@yielded/auth": "0.1.0-beta.47", "@yielded/auth-simplewebauthn": "0.1.0-beta.47", "clsx": "^2.1.1", diff --git a/bun.lock b/bun.lock index d9f609bff..01939f5aa 100644 --- a/bun.lock +++ b/bun.lock @@ -201,7 +201,6 @@ "@simplewebauthn/browser": "^14.0.0", "@tanstack/browser-db-sqlite-persistence": "1.0.0-rc.1.1", "@tanstack/db": "1.0.0-rc.1.1", - "@tanstack/hotkeys": "0.22.0", "@yielded/auth": "0.1.0-beta.47", "@yielded/auth-simplewebauthn": "0.1.0-beta.47", "clsx": "^2.1.1", @@ -1363,8 +1362,6 @@ "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], - "@tanstack/hotkeys": ["@tanstack/hotkeys@0.22.0", "", {}, "sha512-gncuZunCZKjB8AqZGydvs1OBTc9ut4PHWc42ovdJJpjjxnhyswnSy7fNKpGv0PKuosnzDtNmwAnGcLcFy0e6zg=="], - "@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.2.1", "", {}, "sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ=="], "@tanstack/query-core": ["@tanstack/query-core@5.105.1", "", {}, "sha512-B/rNlZoZ54w1dlddiUt9oEe1SLM+37c00SKLJEg9YH+7as3Y54P81o9Fh+2ujZ36tXPnQJ5oVe7AcyEWkyFhPg=="], From 0aef96ff93cd90c67d78af61e207ac4a3db2a677 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 14:14:36 +0100 Subject: [PATCH 5/6] fix(web): keybind rows put the label on its own line on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At 390px the key column cut labels to "Sea…" stubs; below md the label now spans the row, with the key and its buttons under it. Co-Authored-By: Claude Opus 5.5 --- .../src/page/my-settings/keybinds/view.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts index 3bce0b3e7..a5560e01b 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts @@ -109,7 +109,7 @@ const recorder = (h: H, model: Model, action: AppAction, shared: Shared): Html = h, { className: twJoin( - "flex h-9 min-w-36 items-center justify-end gap-1 rounded-lg border px-2 outline-none transition-colors duration-150", + "flex h-9 min-w-36 items-center justify-end justify-self-end gap-1 rounded-lg border px-2 outline-none transition-colors duration-150", "focus-visible:ring-2 focus-visible:ring-ring/40", isRecording ? "border-primary bg-primary/5 ring-4 ring-primary/15" @@ -198,7 +198,8 @@ const noticeLine = (h: H, model: Model, action: AppAction): Array => { const rowLabel = (h: H, action: AppAction, isCustom: boolean): Html => h.div( - [h.Class("flex min-w-0 flex-col gap-0.5")], + // On a phone the label takes its own line, so the key column never cuts it to a stub. + [h.Class("flex min-w-0 flex-col gap-0.5 max-md:col-span-full")], [ h.div( [h.Class("flex items-center gap-2")], @@ -216,7 +217,7 @@ const editableRow = (h: H, model: Model, action: AppAction, shared: Shared): Htm [ h.Class( twJoin( - "group/row grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-1.5 px-4 py-2.5 transition-colors duration-150", + "group/row grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-1.5 px-4 py-2.5 transition-colors duration-150 max-md:grid-cols-[1fr_auto]", model.recording === action.id && "bg-primary/[0.03]", ), ), @@ -232,11 +233,15 @@ const editableRow = (h: H, model: Model, action: AppAction, shared: Shared): Htm /** A built-in key: listed so you know it is taken, not changeable. */ const fixedRow = (h: H, action: AppAction, shared: Shared): Html => h.li( - [h.Class("grid grid-cols-[1fr_auto_auto] items-center gap-x-3 px-4 py-2.5")], + [ + h.Class( + "grid grid-cols-[1fr_auto_auto] items-center gap-x-3 gap-y-1.5 px-4 py-2.5 max-md:grid-cols-[1fr_auto]", + ), + ], [ rowLabel(h, action, false), h.span( - [h.Class("flex min-w-36 items-center justify-end gap-2 px-2")], + [h.Class("flex min-w-36 items-center justify-end justify-self-end gap-2 px-2")], appKeymap.keysOf(shared.keys, action.id).map((key) => caps(h, key, shared.keyboardPlatform)), ), h.span( From 6fdd13652f0824ccaf46b244ab06c0740fa14550 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Thu, 12 Nov 2026 14:19:39 +0100 Subject: [PATCH 6/6] fix(web): per-row Reset shows whenever a row is off its default A default dropped in resolve (another action's override took it, with no override of its own) now gets its Reset button too. Co-Authored-By: Claude Opus 5.5 --- .../src/page/my-settings/keybinds/scene.test.ts | 15 +++++++++++++++ .../src/page/my-settings/keybinds/view.ts | 7 +++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts index ae7af2b4a..16f0da5bc 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/scene.test.ts @@ -176,6 +176,21 @@ describe("reset and remove", () => { }) }) + test("a row whose default another override took can still be reset on its own", () => { + // No override of its own: the default was dropped in resolve, as stored overrides can leave it. + Scene.scene( + pageScene(update, view, sharedWith({ "search.open": combo("Mod+I") })), + Scene.given(init().model), + Scene.expect(keyButton("Open Inbox")).toHaveAccessibleName( + "Open Inbox: not set. Press to change.", + ), + Scene.click(Scene.role("button", { name: "Reset to default (Ctrl+I)" })), + Scene.expectOutMessage( + PageOutMessage.RequestedKeybindings({ overrides: { "search.open": null } }), + ), + ) + }) + test("Remove keybind leaves the action unbound", () => { expect( update(init().model, Message.ClickedUnbind({ actionId: "channel.pins" }), sharedWith({})) diff --git a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts index a5560e01b..aaa5ca74b 100644 --- a/apps/web-foldkit/src/page/my-settings/keybinds/view.ts +++ b/apps/web-foldkit/src/page/my-settings/keybinds/view.ts @@ -145,14 +145,17 @@ const iconButton = (h: H, label: string, onPress: Message, icon: Html, className /** Reset and unbind, in fixed slots so the key buttons line up whichever show. */ const rowActions = (h: H, action: AppAction, shared: Shared): Html => { - const isOverridden = appKeymap.isOverridden(shared.keybindingOverrides, action.id) + // Changed: overridden, or its default lost to another action's override (Reset takes it back). + const isChanged = + appKeymap.isOverridden(shared.keybindingOverrides, action.id) || + shared.keys[action.id] !== action.defaultKey const isBound = shared.keys[action.id] !== null const defaultLabel = action.defaultKey === null ? "none" : comboLabel(action.defaultKey, shared.keyboardPlatform) return h.div( [h.Class("flex w-16 shrink-0 items-center justify-end gap-0.5")], [ - isOverridden + isChanged ? iconButton( h, `Reset to default (${defaultLabel})`,