feat(shortcuts): allow shortcuts to be unassigned (#2510)
Allow users to clear and restore shortcut bindings, persist the explicit unassigned state, and keep shortcut displays and actions aligned with the binding that fires. Includes focused unit coverage and desktop renderer Playwright coverage.
This commit is contained in:
@@ -1,24 +1,34 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, Text } from "react-native";
|
||||
import { View, Text, type PressableStateCallbackType } from "react-native";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { MoreHorizontal, Pencil, Undo2, X } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { SettingsSection } from "@/screens/settings/settings-section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import {
|
||||
buildKeyboardShortcutHelpSections,
|
||||
getBindingIdForAction,
|
||||
getDefaultKeysForAction,
|
||||
resolveShortcutKeysForAction,
|
||||
type KeyboardShortcutHelpRow,
|
||||
} from "@/keyboard/keyboard-shortcuts";
|
||||
import {
|
||||
chordStringToShortcutKeys,
|
||||
comboStringToShortcutKeys,
|
||||
heldModifiersFromEvent,
|
||||
keyboardEventToComboString,
|
||||
} from "@/keyboard/shortcut-string";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
@@ -27,6 +37,18 @@ import { getDesktopHost } from "@/desktop/host";
|
||||
|
||||
const EMPTY_CAPTURED_COMBOS: string[] = [];
|
||||
|
||||
const ThemedMoreHorizontal = withUnistyles(MoreHorizontal);
|
||||
const ThemedPencil = withUnistyles(Pencil);
|
||||
const ThemedUndo2 = withUnistyles(Undo2);
|
||||
const ThemedX = withUnistyles(X);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const bindLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const clearLeadingIcon = <ThemedX size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
const resetLeadingIcon = <ThemedUndo2 size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
|
||||
function ShortcutSequence({
|
||||
chord,
|
||||
heldModifiers,
|
||||
@@ -53,32 +75,42 @@ function ShortcutSequence({
|
||||
interface ShortcutRowContainerProps {
|
||||
row: KeyboardShortcutHelpRow;
|
||||
bindingId: string | null;
|
||||
overrideCombo: string | undefined;
|
||||
displayChord: ShortcutKey[][] | null;
|
||||
hasOverride: boolean;
|
||||
hasDefault: boolean;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
heldModifiers: string | null;
|
||||
onStartCapture: (bindingId: string) => void;
|
||||
onSaveCapture: () => void;
|
||||
onCancelCapture: () => void;
|
||||
onClearOverride: (bindingId: string) => void;
|
||||
onRemoveOverride: (bindingId: string) => void;
|
||||
}
|
||||
|
||||
function ShortcutRowContainer({
|
||||
row,
|
||||
bindingId,
|
||||
overrideCombo,
|
||||
displayChord,
|
||||
hasOverride,
|
||||
hasDefault,
|
||||
isCapturing,
|
||||
capturedCombos,
|
||||
heldModifiers,
|
||||
onStartCapture,
|
||||
onSaveCapture,
|
||||
onCancelCapture,
|
||||
onClearOverride,
|
||||
onRemoveOverride,
|
||||
}: ShortcutRowContainerProps) {
|
||||
const handleRebind = useCallback(() => {
|
||||
if (bindingId) onStartCapture(bindingId);
|
||||
}, [bindingId, onStartCapture]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
if (bindingId) onClearOverride(bindingId);
|
||||
}, [bindingId, onClearOverride]);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (bindingId) onRemoveOverride(bindingId);
|
||||
}, [bindingId, onRemoveOverride]);
|
||||
@@ -87,75 +119,204 @@ function ShortcutRowContainer({
|
||||
<ShortcutRow
|
||||
row={row}
|
||||
bindingId={bindingId}
|
||||
overrideCombo={overrideCombo}
|
||||
displayChord={displayChord}
|
||||
hasOverride={hasOverride}
|
||||
hasDefault={hasDefault}
|
||||
isCapturing={isCapturing}
|
||||
capturedCombos={capturedCombos}
|
||||
heldModifiers={heldModifiers}
|
||||
onRebind={handleRebind}
|
||||
onDone={onSaveCapture}
|
||||
onCancel={onCancelCapture}
|
||||
onClear={handleClear}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutRowKeys({
|
||||
displayChord,
|
||||
isCapturing,
|
||||
capturedCombos,
|
||||
heldModifiers,
|
||||
}: {
|
||||
displayChord: ShortcutKey[][] | null;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
heldModifiers: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isCapturing) {
|
||||
return <ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />;
|
||||
}
|
||||
if (displayChord === null) {
|
||||
return <Text style={styles.unassignedText}>{t("settings.shortcuts.unassigned")}</Text>;
|
||||
}
|
||||
return <Shortcut chord={displayChord} />;
|
||||
}
|
||||
|
||||
function ShortcutActionsMenu({
|
||||
row,
|
||||
bindLabel,
|
||||
showClear,
|
||||
showReset,
|
||||
onRebind,
|
||||
onClear,
|
||||
onReset,
|
||||
}: {
|
||||
row: KeyboardShortcutHelpRow;
|
||||
bindLabel: "bind" | "rebind";
|
||||
showClear: boolean;
|
||||
showReset: boolean;
|
||||
onRebind: () => void;
|
||||
onClear: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const triggerStyle = useCallback(
|
||||
({
|
||||
pressed,
|
||||
hovered,
|
||||
open,
|
||||
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) => [
|
||||
styles.menuButton,
|
||||
(hovered || open) && styles.menuButtonHovered,
|
||||
pressed && styles.menuButtonPressed,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("settings.shortcuts.actions.menu", { name: t(row.labelKey) })}
|
||||
testID={`shortcut-actions-${row.id}`}
|
||||
>
|
||||
{({ hovered, open }) => (
|
||||
<ThemedMoreHorizontal
|
||||
size={14}
|
||||
uniProps={hovered || open ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuItem
|
||||
leading={bindLeadingIcon}
|
||||
onSelect={onRebind}
|
||||
testID={`shortcut-bind-${row.id}`}
|
||||
>
|
||||
{t(`settings.shortcuts.actions.${bindLabel}`)}
|
||||
</DropdownMenuItem>
|
||||
{showClear && (
|
||||
<DropdownMenuItem
|
||||
leading={clearLeadingIcon}
|
||||
onSelect={onClear}
|
||||
testID={`shortcut-clear-${row.id}`}
|
||||
>
|
||||
{t("settings.shortcuts.actions.clear")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showReset && (
|
||||
<DropdownMenuItem
|
||||
leading={resetLeadingIcon}
|
||||
onSelect={onReset}
|
||||
testID={`shortcut-reset-${row.id}`}
|
||||
>
|
||||
{t("settings.shortcuts.actions.reset")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutRow({
|
||||
row,
|
||||
bindingId,
|
||||
overrideCombo,
|
||||
displayChord,
|
||||
hasOverride,
|
||||
hasDefault,
|
||||
isCapturing,
|
||||
capturedCombos,
|
||||
heldModifiers,
|
||||
onRebind,
|
||||
onDone,
|
||||
onCancel,
|
||||
onClear,
|
||||
onReset,
|
||||
}: {
|
||||
row: KeyboardShortcutHelpRow;
|
||||
bindingId: string | null;
|
||||
overrideCombo: string | undefined;
|
||||
displayChord: ShortcutKey[][] | null;
|
||||
hasOverride: boolean;
|
||||
hasDefault: boolean;
|
||||
isCapturing: boolean;
|
||||
capturedCombos: string[];
|
||||
heldModifiers: string | null;
|
||||
onRebind: () => void;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
onClear: () => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const displayChord = useMemo(
|
||||
() => (overrideCombo ? chordStringToShortcutKeys(overrideCombo) : [row.keys]),
|
||||
[overrideCombo, row.keys],
|
||||
);
|
||||
const rowStyle = useMemo(() => [styles.row, isCapturing && styles.rowCapturing], [isCapturing]);
|
||||
|
||||
const isBindable = bindingId !== null;
|
||||
const showDone = isCapturing && capturedCombos.length > 0;
|
||||
const showClear = displayChord !== null;
|
||||
// Reset restores the default, so it is only meaningful when there is a
|
||||
// default to restore. A binding that ships without one would otherwise show a
|
||||
// Reset that lands on the same "Not set" state Clear already produced.
|
||||
const showReset = hasOverride && hasDefault;
|
||||
// Nothing is bound in the unassigned state, so there is nothing to *re*-bind.
|
||||
const bindLabel = displayChord === null ? "bind" : "rebind";
|
||||
|
||||
return (
|
||||
<View style={rowStyle}>
|
||||
<Text style={styles.rowLabel}>{t(row.labelKey)}</Text>
|
||||
<View style={styles.rowActions}>
|
||||
<View style={styles.rowKeys}>
|
||||
<ShortcutRowKeys
|
||||
displayChord={displayChord}
|
||||
isCapturing={isCapturing}
|
||||
capturedCombos={capturedCombos}
|
||||
heldModifiers={heldModifiers}
|
||||
/>
|
||||
</View>
|
||||
{isCapturing ? (
|
||||
<ShortcutSequence chord={capturedCombos} heldModifiers={heldModifiers} />
|
||||
) : (
|
||||
<Shortcut chord={displayChord} />
|
||||
)}
|
||||
{bindingId !== null && (
|
||||
<>
|
||||
{isCapturing && capturedCombos.length > 0 ? (
|
||||
{showDone && (
|
||||
<Button variant="ghost" size="sm" onPress={onDone}>
|
||||
{t("settings.shortcuts.actions.done")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button variant="ghost" size="sm" onPress={isCapturing ? onCancel : onRebind}>
|
||||
{isCapturing
|
||||
? t("settings.shortcuts.actions.cancel")
|
||||
: t("settings.shortcuts.actions.rebind")}
|
||||
</Button>
|
||||
)}
|
||||
{isBindable && (
|
||||
<Button variant="ghost" size="sm" onPress={onCancel}>
|
||||
{t("settings.shortcuts.actions.cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overrideCombo !== undefined && !isCapturing && (
|
||||
<Button variant="ghost" size="sm" onPress={onReset}>
|
||||
<Text style={styles.resetText}>{t("settings.shortcuts.actions.reset")}</Text>
|
||||
</Button>
|
||||
) : (
|
||||
// Fixed slot, occupied or not, so the keys column keeps one rail on
|
||||
// every row instead of sliding with whatever actions the row offers.
|
||||
<View style={styles.menuSlot}>
|
||||
{isBindable && (
|
||||
<ShortcutActionsMenu
|
||||
row={row}
|
||||
bindLabel={bindLabel}
|
||||
showClear={showClear}
|
||||
showReset={showReset}
|
||||
onRebind={onRebind}
|
||||
onClear={onClear}
|
||||
onReset={onReset}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -167,7 +328,7 @@ export function KeyboardShortcutsSection() {
|
||||
const [capturingBindingId, setCapturingBindingId] = useState<string | null>(null);
|
||||
const [capturedCombos, setCapturedCombos] = useState<string[]>([]);
|
||||
const [heldModifiers, setHeldModifiers] = useState<string | null>(null);
|
||||
const { overrides, hasOverrides, setOverride, removeOverride, resetAll } =
|
||||
const { overrides, hasOverrides, setOverride, clearOverride, removeOverride, resetAll } =
|
||||
useKeyboardShortcutOverrides();
|
||||
const setCapturingShortcut = useKeyboardShortcutsStore((s) => s.setCapturingShortcut);
|
||||
const capturing = useKeyboardShortcutsStore((s) => s.capturingShortcut);
|
||||
@@ -256,6 +417,10 @@ export function KeyboardShortcutsSection() {
|
||||
}, [capturing]);
|
||||
|
||||
const handleResetAll = useCallback(() => void resetAll(), [resetAll]);
|
||||
const handleClearOverride = useCallback(
|
||||
(bindingId: string) => void clearOverride(bindingId),
|
||||
[clearOverride],
|
||||
);
|
||||
const handleRemoveOverride = useCallback(
|
||||
(bindingId: string) => void removeOverride(bindingId),
|
||||
[removeOverride],
|
||||
@@ -288,18 +453,23 @@ export function KeyboardShortcutsSection() {
|
||||
>
|
||||
<View style={settingsStyles.card}>
|
||||
{section.rows.map(function (row, index) {
|
||||
const bindingId = getBindingIdForAction(row.id, {
|
||||
isMac,
|
||||
isDesktop: isDesktopApp,
|
||||
});
|
||||
const overrideCombo = bindingId ? overrides[bindingId] : undefined;
|
||||
const platform = { isMac, isDesktop: isDesktopApp };
|
||||
const bindingId = getBindingIdForAction(row.id, platform);
|
||||
const displayChord = resolveShortcutKeysForAction(row.id, overrides, platform);
|
||||
// `in`, not a truthiness check: an unassigned shortcut stores
|
||||
// null, and Reset has to stay available to undo it.
|
||||
const hasOverride = bindingId !== null && bindingId in overrides;
|
||||
// A binding authored with `combo: ""` has nothing to reset to.
|
||||
const hasDefault = getDefaultKeysForAction(row.id, platform) !== null;
|
||||
|
||||
return (
|
||||
<View key={row.id}>
|
||||
<ShortcutRowContainer
|
||||
row={row}
|
||||
bindingId={bindingId}
|
||||
overrideCombo={overrideCombo}
|
||||
displayChord={displayChord}
|
||||
hasOverride={hasOverride}
|
||||
hasDefault={hasDefault}
|
||||
isCapturing={capturingBindingId === bindingId}
|
||||
capturedCombos={
|
||||
capturingBindingId === bindingId ? capturedCombos : EMPTY_CAPTURED_COMBOS
|
||||
@@ -308,6 +478,7 @@ export function KeyboardShortcutsSection() {
|
||||
onStartCapture={startCapture}
|
||||
onSaveCapture={saveCapture}
|
||||
onCancelCapture={cancelCapture}
|
||||
onClearOverride={handleClearOverride}
|
||||
onRemoveOverride={handleRemoveOverride}
|
||||
/>
|
||||
{index < section.rows.length - 1 && <View style={styles.separator} />}
|
||||
@@ -343,11 +514,32 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
rowKeys: {
|
||||
alignItems: "flex-end",
|
||||
},
|
||||
menuSlot: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
},
|
||||
menuButton: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
menuButtonHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
menuButtonPressed: {
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
capturingText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
resetText: {
|
||||
unassignedText: {
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
separator: {
|
||||
|
||||
Reference in New Issue
Block a user