feat(actions): improve keyboard shortcut display

Previously, the logic for displaying keyboard shortcuts was all over the place. This commit creates a single KeyboardShortcut component that is responsible for displaying it. It slightly changes some styles in the Kbd component, and also adds the Tauri `os` plugin to determine whether to display Mac symbols or Windows names. It also maps cmd to Ctrl on Windows.
This commit is contained in:
ByteAtATime 2025-06-29 11:32:18 -07:00
parent 4a580ce5b9
commit 8852c73f5a
No known key found for this signature in database
16 changed files with 151 additions and 88 deletions

View file

@ -24,6 +24,7 @@
"@tauri-apps/plugin-global-shortcut": "~2.2.1",
"@tauri-apps/plugin-http": "~2.4.4",
"@tauri-apps/plugin-opener": "~2",
"@tauri-apps/plugin-os": "~2.3.0",
"@tauri-apps/plugin-shell": "~2.2.1",
"embla-carousel-svelte": "^8.6.0",
"fuse.js": "^7.1.0",

10
pnpm-lock.yaml generated
View file

@ -35,6 +35,9 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ~2
version: 2.2.7
'@tauri-apps/plugin-os':
specifier: ~2.3.0
version: 2.3.0
'@tauri-apps/plugin-shell':
specifier: ~2.2.1
version: 2.2.1
@ -1031,6 +1034,9 @@ packages:
'@tauri-apps/plugin-opener@2.2.7':
resolution: {integrity: sha512-uduEyvOdjpPOEeDRrhwlCspG/f9EQalHumWBtLBnp3fRp++fKGLqDOyUhSIn7PzX45b/rKep//ZQSAQoIxobLA==}
'@tauri-apps/plugin-os@2.3.0':
resolution: {integrity: sha512-dm3bDsMuUngpIQdJ1jaMkMfyQpHyDcaTIKTFaAMHoKeUd+Is3UHO2uzhElr6ZZkfytIIyQtSVnCWdW2Kc58f3g==}
'@tauri-apps/plugin-shell@2.2.1':
resolution: {integrity: sha512-G1GFYyWe/KlCsymuLiNImUgC8zGY0tI0Y3p8JgBCWduR5IEXlIJS+JuG1qtveitwYXlfJrsExt3enhv5l2/yhA==}
@ -3542,6 +3548,10 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.5.0
'@tauri-apps/plugin-os@2.3.0':
dependencies:
'@tauri-apps/api': 2.6.0
'@tauri-apps/plugin-shell@2.2.1':
dependencies:
'@tauri-apps/api': 2.5.0

52
src-tauri/Cargo.lock generated
View file

@ -2031,6 +2031,16 @@ dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "gethostname"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55"
dependencies = [
"rustix 1.0.7",
"windows-targets 0.52.6",
]
[[package]]
name = "getrandom"
version = "0.1.16"
@ -3856,6 +3866,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "os_info"
version = "3.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0e1ac5fde8d43c34139135df8ea9ee9465394b2d8d20f032d38998f64afffc3"
dependencies = [
"log",
"plist",
"serde",
"windows-sys 0.52.0",
]
[[package]]
name = "os_pipe"
version = "1.2.2"
@ -4613,6 +4635,7 @@ dependencies = [
"tauri-plugin-global-shortcut",
"tauri-plugin-http",
"tauri-plugin-opener",
"tauri-plugin-os",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tokio",
@ -5515,6 +5538,15 @@ dependencies = [
"syn 2.0.103",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
@ -5878,6 +5910,24 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-os"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05bccb4c6de4299beec5a9b070878a01bce9e2c945aa7a75bcea38bcba4c675d"
dependencies = [
"gethostname 1.0.2",
"log",
"os_info",
"serde",
"serde_json",
"serialize-to-javascript",
"sys-locale",
"tauri",
"tauri-plugin",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.2.2"
@ -7678,7 +7728,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12"
dependencies = [
"gethostname",
"gethostname 0.4.3",
"rustix 0.38.44",
"x11rb-protocol",
]

View file

@ -63,6 +63,7 @@ walkdir = "2.5.0"
notify = "6.1.1"
notify-debouncer-full = "0.3.1"
percent-encoding = "2.3.1"
tauri-plugin-os = "2"
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2"

View file

@ -42,6 +42,7 @@
]
},
"dialog:default",
"fs:default"
"fs:default",
"os:default"
]
}

View file

@ -201,6 +201,7 @@ fn setup_input_listener(app: &tauri::AppHandle) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let app = tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())

View file

@ -10,11 +10,11 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Kbd } from './ui/kbd';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
import * as Select from './ui/select';
import ActionBar from './nodes/shared/ActionBar.svelte';
import ActionMenu from './nodes/shared/ActionMenu.svelte';
import BaseList from './BaseList.svelte';
import KeyboardShortcut from './KeyboardShortcut.svelte';
type Props = {
onBack: () => void;
@ -360,14 +360,14 @@
<Pin class="mr-2 size-4" />
<span>{selectedItem.isPinned ? 'Unpin' : 'Pin'}</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'P', modifiers: ['cmd', 'shift'] })}
<KeyboardShortcut shortcut={{ key: 'P', modifiers: ['cmd', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => handleDelete(selectedItem)}>
<Trash class="mr-2 size-4" />
<span>Delete</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'x', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'x', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</ActionMenu>

View file

@ -8,11 +8,11 @@
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
import { Kbd } from './ui/kbd';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
import ActionBar from './nodes/shared/ActionBar.svelte';
import ActionMenu from './nodes/shared/ActionMenu.svelte';
import BaseList from './BaseList.svelte';
import { open } from '@tauri-apps/plugin-shell';
import KeyboardShortcut from './KeyboardShortcut.svelte';
type Props = {
onBack: () => void;
@ -189,7 +189,7 @@
<ActionBar>
{#snippet primaryAction({ props })}
<Button {...props} onclick={() => handleOpen(selectedItem)}>
Open <Kbd></Kbd>
Open <KeyboardShortcut shortcut={{ key: 'enter', modifiers: [] }} />
</Button>
{/snippet}
{#snippet actions()}
@ -198,14 +198,14 @@
<Eye class="mr-2 size-4" />
<span>Show in File Manager</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'Enter', modifiers: ['cmd'] })}
<KeyboardShortcut shortcut={{ key: 'Enter', modifiers: ['cmd'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => handleCopyPath(selectedItem)}>
<Copy class="mr-2 size-4" />
<span>Copy Path</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'c', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'c', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
@ -213,7 +213,7 @@
<Trash class="mr-2 size-4" />
<span>Move to Trash</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'x', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'x', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</ActionMenu>

View file

@ -0,0 +1,48 @@
<script lang="ts">
import type { KeyboardShortcut } from '$lib/props';
import { platform } from '@tauri-apps/plugin-os';
import { Kbd } from './ui/kbd';
let { shortcut }: { shortcut: KeyboardShortcut } = $props();
const macModifierMap = {
cmd: '⌘',
ctrl: '⌃',
opt: '⌥',
shift: '⇧'
};
const standardModifierMap = {
cmd: 'Ctrl',
ctrl: 'Ctrl',
opt: 'Alt',
shift: 'Shift'
};
const modifierMap = platform() === 'macos' ? macModifierMap : standardModifierMap;
const keyMap: Partial<Record<KeyboardShortcut['key'], string>> = {
return: '⏎',
enter: '⏎',
delete: '⌫',
backspace: '⌫',
deleteForward: '⌦',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
tab: '⇥',
escape: '⎋',
space: '␣'
};
const symbols = shortcut.modifiers
.map((modifier) => modifierMap[modifier])
.concat(keyMap[shortcut.key] ?? shortcut.key.toUpperCase());
</script>
<div class="flex gap-0.5">
{#each symbols as symbol}
<Kbd>{symbol}</Kbd>
{/each}
</div>

View file

@ -7,10 +7,10 @@
import ListItemBase from './nodes/shared/ListItemBase.svelte';
import { Kbd } from './ui/kbd';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
import ActionBar from './nodes/shared/ActionBar.svelte';
import ActionMenu from './nodes/shared/ActionMenu.svelte';
import BaseList from './BaseList.svelte';
import KeyboardShortcut from './KeyboardShortcut.svelte';
type Props = {
onBack: () => void;
@ -245,7 +245,7 @@
<Trash class="mr-2 size-4" />
<span>Delete</span>
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'x', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'x', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
</ActionMenu>

View file

@ -5,7 +5,7 @@
import { Kbd } from '$lib/components/ui/kbd';
import ActionMenu from '$lib/components/nodes/shared/ActionMenu.svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { shortcutToText } from '$lib/renderKey';
import KeyboardShortcut from '../KeyboardShortcut.svelte';
type Props = {
selectedItem: UnifiedItem | undefined;
@ -48,13 +48,13 @@
<DropdownMenu.Item onclick={barActions.handleEnter}>
Copy Answer
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })}
<KeyboardShortcut shortcut={{ key: 'c', modifiers: ['ctrl', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setSearchText(selectedItem.data.result)}>
Put Answer in Search Bar
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'enter', modifiers: ['ctrl', 'shift'] })}
<KeyboardShortcut shortcut={{ key: 'enter', modifiers: ['ctrl', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{:else if selectedItem.type === 'plugin'}
@ -65,14 +65,14 @@
<DropdownMenu.Item onclick={barActions.handleCopyDeeplink}>
Copy Deeplink
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'c', modifiers: ['ctrl', 'shift'] })}
<KeyboardShortcut shortcut={{ key: 'c', modifiers: ['ctrl', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleConfigureCommand}>
Configure Command
<DropdownMenu.Shortcut>
{shortcutToText({ key: ',', modifiers: ['ctrl', 'shift'] })}
<KeyboardShortcut shortcut={{ key: ',', modifiers: ['ctrl', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{:else if selectedItem.type === 'app'}
@ -83,20 +83,20 @@
<DropdownMenu.Item onclick={barActions.handleCopyAppName}>
Copy Name
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: '.', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onclick={barActions.handleCopyAppPath}>
Copy Path
<DropdownMenu.Shortcut>
{shortcutToText({ key: '.', modifiers: ['ctrl', 'shift'] })}
<KeyboardShortcut shortcut={{ key: '.', modifiers: ['ctrl', 'shift'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={barActions.handleHideApp}>
Hide Application
<DropdownMenu.Shortcut>
{shortcutToText({ key: 'h', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'h', modifiers: ['ctrl'] }} />
</DropdownMenu.Shortcut>
</DropdownMenu.Item>
{/if}

View file

@ -1,9 +1,9 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Kbd } from '../ui/kbd';
import { keyEventMatches, type KeyboardShortcut } from '$lib/props';
import { shortcutToText } from '$lib/renderKey';
import { keyEventMatches, type KeyboardShortcut as KeyboardShortcutType } from '$lib/props';
import type { Toast } from '$lib/ui.svelte';
import KeyboardShortcut from '../KeyboardShortcut.svelte';
type Props = {
toast: Toast;
@ -17,7 +17,7 @@
const availableActions: {
type: 'primary' | 'secondary';
title: string;
shortcut?: KeyboardShortcut;
shortcut?: KeyboardShortcutType;
}[] = [];
if (toast?.primaryAction) {
@ -88,7 +88,9 @@
<DropdownMenu.Item onselect={() => handleActionSelect(action.type)}>
{action.title}
{#if action.shortcut}
<DropdownMenu.Shortcut>{shortcutToText(action.shortcut)}</DropdownMenu.Shortcut>
<DropdownMenu.Shortcut>
<KeyboardShortcut shortcut={action.shortcut} />
</DropdownMenu.Shortcut>
{/if}
</DropdownMenu.Item>
{/each}

View file

@ -1,15 +1,15 @@
<script lang="ts">
import type { KeyboardShortcut } from '$lib/props/actions';
import type { KeyboardShortcut as KeyboardShortcutType } from '$lib/props/actions';
import { DropdownMenuItem, DropdownMenuShortcut } from '$lib/components/ui/dropdown-menu';
import { Button } from '$lib/components/ui/button';
import { shortcutToText } from '$lib/renderKey';
import type { ImageLike } from '$lib/props';
import Icon from '$lib/components/Icon.svelte';
import { Kbd } from '$lib/components/ui/kbd';
import KeyboardShortcut from '$lib/components/KeyboardShortcut.svelte';
type Props = {
title: string;
shortcut?: KeyboardShortcut | null;
shortcut?: KeyboardShortcutType | null;
icon?: ImageLike;
isPrimaryAction?: boolean;
isSecondaryAction?: boolean;
@ -41,14 +41,16 @@
{title}
{#if isPrimaryAction}
<DropdownMenuShortcut>
{shortcutToText({ key: 'enter', modifiers: [] })}
<KeyboardShortcut shortcut={{ key: 'enter', modifiers: [] }} />
</DropdownMenuShortcut>
{:else if isSecondaryAction}
<DropdownMenuShortcut>
{shortcutToText({ key: 'enter', modifiers: ['ctrl'] })}
<KeyboardShortcut shortcut={{ key: 'enter', modifiers: ['ctrl'] }} />
</DropdownMenuShortcut>
{:else if shortcut}
<DropdownMenuShortcut>{shortcutToText(shortcut)}</DropdownMenuShortcut>
<DropdownMenuShortcut>
<KeyboardShortcut {shortcut} />
</DropdownMenuShortcut>
{/if}
</DropdownMenuItem>
{/if}

View file

@ -4,6 +4,7 @@
import { Kbd } from '$lib/components/ui/kbd';
import type { Snippet } from 'svelte';
import { setContext } from 'svelte';
import KeyboardShortcut from '$lib/components/KeyboardShortcut.svelte';
type Props = {
children: Snippet;
@ -33,7 +34,8 @@
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="sm">
Actions <Kbd>⌘ K</Kbd>
Actions
<KeyboardShortcut shortcut={{ key: 'k', modifiers: ['cmd'] }} />
</Button>
{/snippet}
</DropdownMenu.Trigger>

View file

@ -3,7 +3,7 @@
import type { WithChildren } from 'bits-ui';
const style = tv({
base: 'inline-flex place-items-center justify-center gap-1 rounded-md p-0.5',
base: 'inline-flex place-items-center justify-center gap-1 rounded-md p-0.5 font-sans',
variants: {
variant: {
outline: 'border-border bg-transparent text-muted-foreground border',
@ -12,7 +12,7 @@
},
size: {
sm: 'min-w-6 gap-1.5 p-0.5 px-1 text-sm',
default: 'gap-1.5 p-1 px-2',
default: 'min-w-6 gap-1.5 p-1 text-xs',
lg: 'min-w-9 gap-2 p-1 px-3 text-lg'
}
}

View file

@ -1,55 +0,0 @@
import type { KeyboardShortcut } from '$lib/props/actions';
function formatShortcutParts(parts: KeyboardShortcut, isMac: boolean): string {
const modifierMap = {
mac: {
cmd: '⌘',
ctrl: '⌃',
opt: '⌥',
shift: '⇧'
},
other: {
cmd: 'Win',
ctrl: 'Ctrl',
opt: 'Alt',
shift: 'Shift'
}
};
const keyMap: Partial<Record<KeyboardShortcut['key'], string>> = {
return: '⏎',
enter: '⏎',
delete: '⌫',
backspace: '⌫',
deleteForward: '⌦',
arrowUp: '↑',
arrowDown: '↓',
arrowLeft: '←',
arrowRight: '→',
tab: '⇥',
escape: '⎋',
space: '␣'
};
const currentModifiers = isMac ? modifierMap.mac : modifierMap.other;
const modifierStrings = parts.modifiers.map((mod) => currentModifiers[mod]);
const keyString = keyMap[parts.key] ?? parts.key.toUpperCase();
const allParts = [...modifierStrings, keyString];
return allParts.join(' + ');
}
export function shortcutToText(shortcut: KeyboardShortcut, forceOS?: 'macOS' | 'windows'): string {
const isMac = forceOS
? forceOS === 'macOS'
: typeof navigator !== 'undefined' && /Mac/i.test(navigator.platform);
if ('modifiers' in shortcut) {
return formatShortcutParts(shortcut, isMac);
} else {
return formatShortcutParts(shortcut, true);
}
}