Proof of concept of global interactionMap

This commit is contained in:
Frank Noirot
2024-05-14 09:40:30 -04:00
parent 21f10c8d92
commit eba79867d8
20 changed files with 591 additions and 222 deletions

124
src-tauri/Cargo.lock generated
View File

@ -169,9 +169,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.82"
version = "1.0.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f538837af36e6f6a9be0faa67f9a314f8119e4e4b5867c6ab40ed60360142519"
checksum = "25bdb32cbbdce2b519a9cd7df3a678443100e265d5e25ca763b7572a5104f5f3"
dependencies = [
"backtrace",
]
@ -334,7 +334,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -369,7 +369,7 @@ checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -415,7 +415,7 @@ checksum = "3c87f3f15e7794432337fc718554eaa4dc8f04c9677a950ffe366f20a162ae42"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -564,7 +564,7 @@ dependencies = [
"proc-macro-crate 3.1.0",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"syn_derive",
]
@ -873,7 +873,7 @@ dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1075,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
dependencies = [
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1085,7 +1085,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f"
dependencies = [
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1109,7 +1109,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim 0.10.0",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1120,7 +1120,7 @@ checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f"
dependencies = [
"darling_core",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1167,7 +1167,7 @@ checksum = "377af281d8f23663862a7c84623bc5dcf7f8c44b13c7496a590bdc157f941a43"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"synstructure",
]
@ -1204,7 +1204,7 @@ dependencies = [
"regex",
"serde",
"serde_tokenstream",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1287,7 +1287,7 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1385,7 +1385,7 @@ checksum = "5c785274071b1b420972453b306eeca06acf4633829db4223b58a2a8c5953bc4"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1536,7 +1536,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1652,7 +1652,7 @@ checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1928,7 +1928,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -1956,7 +1956,7 @@ dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -2031,7 +2031,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -2640,7 +2640,7 @@ checksum = "0611fc9b9786175da21d895ffa0f65039e19c9111e94a41b7af999e3b95f045f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -3362,7 +3362,7 @@ dependencies = [
"regex",
"regex-syntax 0.7.5",
"structmeta 0.2.0",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -3376,7 +3376,7 @@ dependencies = [
"regex",
"regex-syntax 0.8.3",
"structmeta 0.3.0",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -3518,7 +3518,7 @@ dependencies = [
"phf_shared 0.11.2",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -3586,7 +3586,7 @@ checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4424,7 +4424,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4509,9 +4509,9 @@ dependencies = [
[[package]]
name = "serde"
version = "1.0.200"
version = "1.0.201"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc6f9cc94d67c0e21aaf7eda3a010fd3af78ebf6e096aa6e2e13c79749cce4f"
checksum = "780f1cebed1629e4753a1a38a3c72d30b97ec044f0aef68cb26650a3c5cf363c"
dependencies = [
"serde_derive",
]
@ -4527,13 +4527,13 @@ dependencies = [
[[package]]
name = "serde_derive"
version = "1.0.200"
version = "1.0.201"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "856f046b9400cee3c8c94ed572ecdb752444c24528c035cd35882aad6f492bcb"
checksum = "c5e405930b9796f1c00bee880d03fc7e0bb4b9a11afc776885ffe84320da2865"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4544,7 +4544,7 @@ checksum = "330f01ce65a3a5fe59a60c82f3c9a024b573b8a6e875bd233fe5f934e71d54e3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4577,7 +4577,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4598,7 +4598,7 @@ dependencies = [
"proc-macro2",
"quote",
"serde",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4640,7 +4640,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4908,7 +4908,7 @@ dependencies = [
"proc-macro2",
"quote",
"structmeta-derive 0.2.0",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4920,7 +4920,7 @@ dependencies = [
"proc-macro2",
"quote",
"structmeta-derive 0.3.0",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4931,7 +4931,7 @@ checksum = "a60bcaff7397072dca0017d1db428e30d5002e00b6847703e2e42005c95fbe00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4942,7 +4942,7 @@ checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4964,7 +4964,7 @@ dependencies = [
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -4997,9 +4997,9 @@ dependencies = [
[[package]]
name = "syn"
version = "2.0.60"
version = "2.0.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "909518bc7b1c9b779f1bbf07f2929d35af9f0f37e47c6e9ef7f9dddc1e1821f3"
checksum = "bf5be731623ca1a1fb7d8be6f261a3be6d3e2337b8a1f97be944d020c8fcb704"
dependencies = [
"proc-macro2",
"quote",
@ -5015,7 +5015,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -5032,7 +5032,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -5249,7 +5249,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"syn 2.0.60",
"syn 2.0.63",
"tauri-utils",
"thiserror",
"time",
@ -5267,7 +5267,7 @@ dependencies = [
"heck 0.4.1",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"tauri-codegen",
"tauri-utils",
]
@ -5602,22 +5602,22 @@ checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c"
[[package]]
name = "thiserror"
version = "1.0.59"
version = "1.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0126ad08bff79f29fc3ae6a55cc72352056dfff61e3ff8bb7129476d44b23aa"
checksum = "579e9083ca58dd9dcf91a9923bb9054071b9ebbd800b342194c9feb0ee89fc18"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.59"
version = "1.0.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1cd413b5d558b4c5bf3680e324a6fa5014e7b7c067a51e69dbdf47eb7148b66"
checksum = "e2470041c06ec3ac1ab38d0356a6119054dedaea53e12fbefc0de730a1c08524"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -5706,7 +5706,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -5895,7 +5895,7 @@ checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -5924,7 +5924,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -6048,7 +6048,7 @@ dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"termcolor",
]
@ -6261,7 +6261,7 @@ dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -6360,7 +6360,7 @@ dependencies = [
"once_cell",
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"wasm-bindgen-shared",
]
@ -6394,7 +6394,7 @@ checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@ -6535,7 +6535,7 @@ checksum = "ac1345798ecd8122468840bcdf1b95e5dc6d2206c5e4b0eafa078d061f59c9bc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -6641,7 +6641,7 @@ checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -6652,7 +6652,7 @@ checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]
@ -7094,7 +7094,7 @@ checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.60",
"syn 2.0.63",
]
[[package]]

View File

@ -31,6 +31,7 @@ import LspProvider from 'components/LspProvider'
import { KclContextProvider } from 'lang/KclProvider'
import { BROWSER_PROJECT_NAME } from 'lib/constants'
import { getState, setState } from 'lib/tauri'
import { InteractionMapMachineProvider } from 'components/InteractionMapMachineProvider'
const router = createBrowserRouter([
{
@ -39,6 +40,7 @@ const router = createBrowserRouter([
/* Make sure auth is the outermost provider or else we will have
* inefficient re-renders, use the react profiler to see. */
element: (
<InteractionMapMachineProvider>
<CommandBarProvider>
<SettingsAuthProvider>
<LspProvider>
@ -48,6 +50,7 @@ const router = createBrowserRouter([
</LspProvider>
</SettingsAuthProvider>
</CommandBarProvider>
</InteractionMapMachineProvider>
),
errorElement: <ErrorPage />,
children: [

View File

@ -11,10 +11,29 @@ import {
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useStore } from 'useStore'
import { useShouldDisableModelingActions } from 'hooks/useShouldDisableModelingActions'
import { useInteractionMap } from 'hooks/useInteractionMap'
export const Toolbar = () => {
const { commandBarSend } = useCommandsContext()
const { state, send, context } = useModelingContext()
const shouldDisableModelingActions = useShouldDisableModelingActions()
useInteractionMap(
[
{
name: 'extrude',
title: 'Extrude',
sequence: 'shift+e',
action: () =>
commandBarSend({
type: 'Find and select command',
data: { name: 'Extrude', ownerMachine: 'modeling' },
}),
guard: () => !shouldDisableModelingActions && state.matches('idle'),
},
],
[shouldDisableModelingActions, commandBarSend, state]
)
const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const iconClassName =
'group-disabled:text-chalkboard-50 group-enabled:group-hover:!text-chalkboard-10 group-pressed:!text-chalkboard-10'

View File

@ -113,8 +113,6 @@ function CommandArgOptionInput({
onChange={(event) => setQuery(event.target.value)}
className="flex-grow px-2 py-1 border-b border-b-chalkboard-100 dark:border-b-chalkboard-80 !bg-transparent focus:outline-none"
onKeyDown={(event) => {
if (event.metaKey && event.key === 'k')
commandBarSend({ type: 'Close' })
if (event.key === 'Backspace' && !event.currentTarget.value) {
stepBack()
}

View File

@ -1,11 +1,12 @@
import { Dialog, Popover, Transition } from '@headlessui/react'
import { Fragment, useEffect } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { Fragment, useEffect, useMemo } from 'react'
import { useCommandsContext } from 'hooks/useCommandsContext'
import CommandBarArgument from './CommandBarArgument'
import CommandComboBox from '../CommandComboBox'
import CommandBarReview from './CommandBarReview'
import { useLocation } from 'react-router-dom'
import { InteractionMapItem } from 'machines/interactionMapMachine'
import { useInteractionMap } from 'hooks/useInteractionMap'
export const CommandBar = () => {
const { pathname } = useLocation()
@ -21,15 +22,35 @@ export const CommandBar = () => {
commandBarSend({ type: 'Close' })
}, [pathname])
// Hook up keyboard shortcuts
useHotkeys(['mod+k', 'mod+/'], () => {
if (commandBarState.context.commands.length === 0) return
if (commandBarState.matches('Closed')) {
commandBarSend({ type: 'Open' })
} else {
commandBarSend({ type: 'Close' })
}
useInteractionMap(
[
{
name: 'toggle',
title: 'Toggle Command Bar',
sequence: 'meta+k',
action: () => {
const type = commandBarState.matches('Closed') ? 'Open' : 'Close'
console.log('toggling command bar', type)
commandBarSend({
type,
})
},
guard: () => true,
ownerId: 'commandBar',
},
{
name: 'close',
title: 'Close Command Bar',
sequence: 'esc',
action: () => {
commandBarSend({ type: 'Close' })
},
guard: () => !commandBarState.matches('Closed'),
ownerId: 'commandBar',
},
],
[commandBarState, commandBarSend]
)
function stepBack() {
if (!currentArgument) {

View File

@ -15,8 +15,7 @@ function CommandBarBasicInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const { commandBarState } = useCommandsContext()
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {

View File

@ -25,7 +25,7 @@ function CommandBarKclInput({
stepBack: () => void
onSubmit: (event: unknown) => void
}) {
const { commandBarSend, commandBarState } = useCommandsContext()
const { commandBarState } = useCommandsContext()
const previouslySetValue = commandBarState.context.argumentsToSubmit[
arg.name
] as KclCommandValue | undefined
@ -38,7 +38,6 @@ function CommandBarKclInput({
previouslySetValue && 'variableName' in previouslySetValue
)
const [canSubmit, setCanSubmit] = useState(true)
useHotkeys('mod + k, mod + /', () => commandBarSend({ type: 'Close' }))
const editorRef = useRef<HTMLDivElement>(null)
const {

View File

@ -43,15 +43,6 @@ function CommandComboBox({
<Combobox.Input
onChange={(event) => setQuery(event.target.value)}
className="w-full bg-transparent focus:outline-none selection:bg-primary/20 dark:selection:bg-primary/40 dark:focus:outline-none"
onKeyDown={(event) => {
if (
(event.metaKey && event.key === 'k') ||
(event.key === 'Backspace' && !event.currentTarget.value)
) {
event.preventDefault()
commandBarSend({ type: 'Close' })
}
}}
placeholder={
(defaultOption && defaultOption.name) ||
placeholder ||

View File

@ -0,0 +1,206 @@
import { useMachine } from '@xstate/react'
import { INTERACTION_MAP_SEPARATOR } from 'lib/constants'
import { isModifierKey, mapKey, sortKeys } from 'lib/keyboard'
import {
MouseButtonName,
interactionMapMachine,
} from 'machines/interactionMapMachine'
import { createContext, useEffect } from 'react'
import toast from 'react-hot-toast'
import {
AnyStateMachine,
StateFrom,
Prop,
InterpreterFrom,
assign,
} from 'xstate'
type MachineContext<T extends AnyStateMachine> = {
state: StateFrom<T>
send: Prop<InterpreterFrom<T>, 'send'>
}
export const InteractionMapMachineContext = createContext(
{} as MachineContext<typeof interactionMapMachine>
)
export function InteractionMapMachineProvider({
children,
}: React.PropsWithChildren<{}>) {
const [state, send] = useMachine(interactionMapMachine, {
logger: (msg) => {
console.log(msg)
},
actions: {
'Add last interaction to sequence': assign({
currentSequence: (context, event) => {
const newSequence = event.data
? context.currentSequence
? context.currentSequence.concat(' ', event.data)
: event.data
: context.currentSequence
console.log('newSequence', newSequence)
return newSequence
},
}),
'Clear sequence': assign({
currentSequence: () => {
console.log('clearing sequence')
return ''
},
}),
'Add to interactionMap': assign({
interactionMap: (context, event) => {
// normalize any interaction sequences to be sorted
const normalizedInteractions = event.data.map((item) => ({
...item,
sequence: item.sequence
.split(' ')
.map((step) =>
step
.split(INTERACTION_MAP_SEPARATOR)
.sort(sortKeys)
.map(mapKey)
.join(INTERACTION_MAP_SEPARATOR)
)
.join(' '),
}))
// Add the new items to the interactionMap and sort by sequence
// making it faster to search for a sequence
const newInteractionMap = [
...context.interactionMap,
...normalizedInteractions,
].sort((a, b) => a.sequence.localeCompare(b.sequence))
console.log('newInteractionMap', newInteractionMap)
return newInteractionMap
},
}),
'Remove from interactionMap': assign({
interactionMap: (context, event) => {
// Filter out any items that have an ownerId that matches event.data
return [
...context.interactionMap.filter(
(item) => item.ownerId !== event.data
),
]
},
}),
},
services: {
'Resolve hotkey by prefix': (context, event) => {
// First determine if we have a mouse or keyboard event
const action =
'key' in event.data
? mapKey(event.data.code)
: mouseButtonToName(event.data.button)
// if the key is already a modifier key, skip everything else and reject
if (isModifierKey(action)) {
// We return an empty string so that we don't clear the currentSequence
return Promise.reject('')
}
const modifiers = [
event.data.ctrlKey && 'ctrl',
event.data.shiftKey && 'shift',
event.data.altKey && 'alt',
event.data.metaKey && 'meta',
].filter((item) => item !== false) as string[]
const step = [action, ...modifiers]
.sort(sortKeys)
.join(INTERACTION_MAP_SEPARATOR)
// Find all the sequences that start with the current sequence
const searchString =
(context.currentSequence ? context.currentSequence + ' ' : '') + step
const matches = context.interactionMap.filter((item) =>
item.sequence.startsWith(searchString)
)
// If we have no matches, reject the promise
if (matches.length === 0) {
return Promise.reject()
}
const exactMatches = matches.filter(
(item) => item.sequence === searchString
)
if (!exactMatches.length) {
// We have a prefix match.
// Reject the promise and return the step
// so we can add it to currentSequence
return Promise.reject(step)
}
// Resolve to just one exact match
const availableExactMatches = exactMatches.filter((item) =>
item.guard(event.data)
)
if (availableExactMatches.length === 0) {
return Promise.reject()
} else {
// return the last-added, available exact match
return Promise.resolve(
availableExactMatches[availableExactMatches.length - 1]
)
}
},
'Execute keymap action': async (_context, event) => {
try {
console.log('Executing action', event.data)
event.data.action()
} catch (error) {
console.error(error)
toast.error('There was an error executing the action.')
}
},
},
guards: {
'There are prefix matches': (_context, event) => {
return event.data !== undefined
},
},
})
// Setting up global event listeners
useEffect(() => {
if (!globalThis || !globalThis.window) {
return
}
const fireEvent = (event: MouseEvent | KeyboardEvent) => {
send({ type: 'Fire event', data: event })
}
window.addEventListener('keydown', fireEvent)
window.addEventListener('mousedown', fireEvent)
return () => {
window.removeEventListener('keydown', fireEvent)
window.removeEventListener('mousedown', fireEvent)
}
}, [])
return (
<InteractionMapMachineContext.Provider value={{ state, send }}>
{children}
</InteractionMapMachineContext.Provider>
)
}
function mouseButtonToName(button: MouseEvent['button']): MouseButtonName {
switch (button) {
case 0:
return 'LeftButton'
case 1:
return 'MiddleButton'
case 2:
return 'RightButton'
default:
return 'LeftButton'
}
}

View File

@ -6,6 +6,17 @@ import { NetworkHealthIndicator } from 'components/NetworkHealthIndicator'
import { HelpMenu } from './HelpMenu'
import { Link, useLocation } from 'react-router-dom'
import { useAbsoluteFilePath } from 'hooks/useAbsoluteFilePath'
import { useInteractionMapContext } from 'hooks/useInteractionMapContext'
function InteractionSequenceInfo() {
const {
state: {
context: { currentSequence },
},
} = useInteractionMapContext()
return <span className="font-mono text-xs">{currentSequence}</span>
}
export function LowerRightControls(props: React.PropsWithChildren) {
const location = useLocation()
@ -17,6 +28,7 @@ export function LowerRightControls(props: React.PropsWithChildren) {
<section className="fixed bottom-2 right-2">
{props.children}
<menu className="flex items-center justify-end gap-3">
<InteractionSequenceInfo />
<a
href={`https://github.com/KittyCAD/modeling-app/releases/tag/v${APP_VERSION}`}
target="_blank"

View File

@ -25,6 +25,8 @@ export type SidebarType =
| 'lspMessages'
| 'variables'
const PANE_KEYBINDING_PREFIX = 'alt+p ' as const
export type SidebarPane = {
id: SidebarType
title: string
@ -41,7 +43,7 @@ export const topPanes: SidebarPane[] = [
title: 'KCL Code',
icon: faCode,
Content: KclEditorPane,
keybinding: 'shift + c',
keybinding: PANE_KEYBINDING_PREFIX + 'c',
Menu: KclEditorMenu,
},
{
@ -49,7 +51,7 @@ export const topPanes: SidebarPane[] = [
title: 'Project Files',
icon: 'folder',
Content: FileTreeInner,
keybinding: 'shift + f',
keybinding: PANE_KEYBINDING_PREFIX + 'f',
Menu: FileTreeMenu,
hideOnPlatform: 'web',
},
@ -61,27 +63,27 @@ export const bottomPanes: SidebarPane[] = [
title: 'Variables',
icon: faSquareRootVariable,
Content: MemoryPane,
keybinding: 'shift + v',
keybinding: PANE_KEYBINDING_PREFIX + 'v',
},
{
id: 'logs',
title: 'Logs',
icon: faCodeCommit,
Content: LogsPane,
keybinding: 'shift + l',
keybinding: PANE_KEYBINDING_PREFIX + 'l',
},
{
id: 'kclErrors',
title: 'KCL Errors',
icon: faExclamationCircle,
Content: KclErrorsPane,
keybinding: 'shift + e',
keybinding: PANE_KEYBINDING_PREFIX + 'e',
},
{
id: 'debug',
title: 'Debug',
icon: faBugSlash,
Content: DebugPane,
keybinding: 'shift + d',
keybinding: PANE_KEYBINDING_PREFIX + 'd',
},
]

View File

@ -1,7 +1,6 @@
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { Resizable } from 're-resizable'
import { useCallback, useEffect, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import { useStore } from 'useStore'
import { Tab } from '@headlessui/react'
import {
@ -194,10 +193,6 @@ function ModelingPaneButton({
currentPane,
togglePane,
}: ModelingPaneButtonProps) {
useHotkeys(paneConfig.keybinding, togglePane, {
scopes: ['modeling'],
})
return (
<Tab
key={paneConfig.id}

View File

@ -0,0 +1,40 @@
import { InteractionMapItem } from 'machines/interactionMapMachine'
import { useEffect, useMemo } from 'react'
import { useInteractionMapContext } from './useInteractionMapContext'
/**
* Custom hook to add an interaction map to the interaction map machine
* from within a component, and remove it when the component unmounts.
* @param interactionMap - An array of interaction map items. You don't need to provide an `ownerId` property, as it will be added automatically.
* @param deps - Any dependencies that should trigger a resetting of the interaction map when they change.
* @param mapId - An optional ID for the interaction map. If not provided, a random UUID will be generated.
*/
export function useInteractionMap(
interactionMap: Omit<InteractionMapItem, 'ownerId'>[],
deps: any[],
mapId?: string
) {
const interactionMachine = useInteractionMapContext()
const mapIdMemoized = useMemo<string>(
() => mapId || crypto.randomUUID(),
[mapId]
)
const interactionMapMemoized = useMemo<InteractionMapItem[]>(
() => interactionMap.map((item) => ({ ...item, ownerId: mapIdMemoized })),
deps
)
useEffect(() => {
interactionMachine.send({
type: 'Add to interaction map',
data: interactionMapMemoized,
})
return () => {
interactionMachine.send({
type: 'Remove from interaction map',
data: mapIdMemoized,
})
}
}, [interactionMapMemoized, mapIdMemoized])
}

View File

@ -0,0 +1,6 @@
import { InteractionMapMachineContext } from 'components/InteractionMapMachineProvider'
import { useContext } from 'react'
export const useInteractionMapContext = () => {
return useContext(InteractionMapMachineContext)
}

View File

@ -0,0 +1,21 @@
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useKclContext } from 'lang/KclProvider'
import { useStore } from 'useStore'
/**
* Custom hook to determine if modeling actions should be disabled
* based on the current network status, KCL execution status, and stream readiness.
* @returns boolean
*/
export function useShouldDisableModelingActions() {
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady,
}))
return overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
}

View File

@ -7,12 +7,7 @@ import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useKclContext } from 'lang/KclProvider'
import { useStore } from 'useStore'
import { useShouldDisableModelingActions } from './useShouldDisableModelingActions'
// This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines =
@ -47,17 +42,13 @@ export default function useStateMachineCommands<
onCancel,
}: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext()
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady,
}))
const shouldDisableModelingActions = useShouldDisableModelingActions()
useEffect(() => {
const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady
const newCommands = state.nextEvents
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
.filter(
(_) => !allCommandsRequireNetwork || !shouldDisableModelingActions
)
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))
.map((type) =>
createMachineCommand<T, S>({
@ -80,5 +71,5 @@ export default function useStateMachineCommands<
data: { commands: newCommands },
})
}
}, [state, overallState, isExecuting, isStreamReady])
}, [state, shouldDisableModelingActions])
}

View File

@ -42,3 +42,5 @@ export const RELEVANT_FILE_TYPES = [
] as const
/** The default name for a tutorial project */
export const ONBOARDING_PROJECT_NAME = 'Tutorial Project $nn'
/** The separator between keys/buttons in an InteractionMapItem's step */
export const INTERACTION_MAP_SEPARATOR = '+'

44
src/lib/keyboard.ts Normal file
View File

@ -0,0 +1,44 @@
/**
* From https://github.com/JohannesKlauss/react-hotkeys-hook/blob/main/src/parseHotkeys.ts
* we don't want to use the whole library (as cool as it is) because it attaches
* new listeners for each hotkey. Just the key parsing part is good for us.
*/
const reservedModifierKeywords = ['shift', 'alt', 'meta', 'mod', 'ctrl']
const mappedKeys: Record<string, string> = {
esc: 'escape',
return: 'enter',
'.': 'period',
',': 'comma',
'-': 'slash',
' ': 'space',
'`': 'backquote',
'#': 'backslash',
'+': 'bracketright',
ShiftLeft: 'shift',
ShiftRight: 'shift',
AltLeft: 'alt',
AltRight: 'alt',
MetaLeft: 'meta',
MetaRight: 'meta',
OSLeft: 'meta',
OSRight: 'meta',
ControlLeft: 'ctrl',
ControlRight: 'ctrl',
}
export function mapKey(key: string): string {
return (mappedKeys[key] || key)
.trim()
.toLowerCase()
.replace(/key|digit|numpad|arrow/, '')
}
export function isModifierKey(key: string) {
return reservedModifierKeywords.includes(key)
}
// Sorts keys in the order of modifier keys, then alphabetically
export function sortKeys(a: string, b: string) {
return isModifierKey(a) ? -1 : isModifierKey(b) ? 1 : a.localeCompare(b)
}

View File

@ -1,99 +0,0 @@
import { createMachine } from 'xstate'
export type InteractionMapItem = {
name: string
title: string
sequence: string
guard: () => boolean
action: () => void
}
export const interactionMachine = createMachine({
context: {
interactionMap: new Set<InteractionMapItem>(),
prefixMatrix: new Set<Set<string>>(),
currentSequence: [] as string[],
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./interactionMachine.typegen').Typegen0,
schema: {
events: {} as
| {
type: 'Update interaction map'
data: InteractionMapItem[]
}
| { type: 'Fire event'; data: MouseEvent | KeyboardEvent }
| { type: 'Update prefix matrix' }
| { type: 'Add last interaction to sequence' }
| { type: 'Clear sequence' }
| { type: 'Resolve hotkey by prefix'; data: MouseEvent | KeyboardEvent },
},
id: 'Interaction Map Actor',
initial: 'Listening for interaction',
on: {
'Update interaction map': {
target: '#Interaction Map Actor',
actions: [
{
type: 'Update interactionMap',
},
{
type: 'Update prefix matrix',
},
],
},
},
description:
'Manages the keymap of actions that can be take with the keyboard, mouse, or combination of the two while using the app.',
states: {
'Listening for interaction': {
on: {
'Fire event': {
target: 'Resolve hotkey',
},
},
},
'Resolve hotkey': {
invoke: {
id: 'resolveHotkeyPrefix',
onDone: {
target: 'Execute keymap event',
},
onError: [
{
target: 'Listening for interaction',
actions: {
type: 'Add last interaction to sequence',
},
cond: {
type: 'There are prefix matches',
},
},
{
target: 'Listening for interaction',
actions: {
type: 'Clear sequence',
},
},
],
src: 'Resolve hotkey by prefix',
},
},
'Execute keymap event': {
exit: {
type: 'Clear sequence',
},
invoke: {
id: 'executeKeymapAction',
onDone: {
target: 'Listening for interaction',
},
onError: {
target: 'Listening for interaction',
},
src: 'Execute keymap action',
},
},
},
})

View File

@ -0,0 +1,119 @@
import { createMachine } from 'xstate'
export type MouseButtonName = `${'Left' | 'Middle' | 'Right'}Button`
export type InteractionMapItem = {
name: string
title: string
sequence: string
guard: (e: MouseEvent | KeyboardEvent) => boolean
action: () => void
ownerId: string
}
export const interactionMapMachine = createMachine({
/** @xstate-layout N4IgpgJg5mDOIC5QEkB2AXMAnAhgY3QEsB7VAAgFkcAHMgQQOKwGI6IIz1izCNt8ipMgFsaAbQAMAXUShqxWIUGpZIAB6IAzAE4AbADoATBIAcAdk0AWCQFYJmiRMMAaEAE9EAWgCMN7-t0JbRNdS0tDM29dbQBfGNc0TFwCEnIqWgYuFgAlMGFiADcwMgAzLGJhHj5k5RFxaVV5RWVVDQRow30TU29rbrsrb1cPBB8-AKDtXytg7R0TOITqgVTKGnpGLH0AGUJYTFReKFKmKqSV0mYAMUIsYrAijEkZJBAmpVTWxDmbfTMI7wmEyWYGGbThYZaUJGKw2SxwmwWSJmRYgRL8FJCdIbLL6XKwYgAGyKZAAFsR0ABrMBuZgQUhgfS8ArEan6O4E4lgAASFOpbgAQm4AAp3EqENTPRoKD6kL4IbzeTSaLreMyWXS6RWaXRmOaQhB2Aw2KyGawOEyInWo9E1VbYzJMPFwIkk8lUmnMbDlLbUQk4dAlJjCdkurm8j2CkViiVS17vFqvNreCSArq6Yw6iLaRyGGwGqK-bRzUKGbyGM0mYuGG3LTFpdaOrb413Fd38r1YH36P0BoNYEMc1sR-lC0VgcWS7wvOQyxOgZNOfxWeHRDPzMsGgFdPSObrKsxwywo+Jouu1B2bfQAUTUYDwAFdMGR+aJaA8wBg6QymagWWywDvR9MAAaRpN9MlSONZ2aT4k0QMIzH0YJvGCGYbGCVNdANTQ4X0DVUzsCswW6WJT1tC4GwyK9b3vJ9ilfdYPy-b0nV7QNg30QC6NA8CaEg0hoLeOc4IXBCQQCGxjDVawKz1eEt0tdMHDMboU20M0zBPU9UGICA4FUCj6zWaismlWC5Xg0YhncRBfH0csdRTWwTEMaIbF0TRa3OYzL1xXZ9k-I4TiwM4MXnYSLJUKz-iQwwTFwzQEvhM1bC3DTVSBTzHBNdzvPC+1GyvFsuTJPkaXM2VorEhVj1+CRdBMctLHUk03JwzR-HLHMLECDyEu8fK7SxIrcVo4CGL499HnQSqIraY9FJVUJgQ1cJUP+CRLDiOIgA */
context: {
interactionMap: [] as InteractionMapItem[],
currentSequence: '' as string,
},
predictableActionArguments: true,
preserveActionOrder: true,
tsTypes: {} as import('./interactionMapMachine.typegen').Typegen0,
schema: {
events: {} as
| {
type: 'Add to interaction map'
data: InteractionMapItem[]
}
| {
type: 'Remove from interaction map'
data: string
}
| { type: 'Fire event'; data: MouseEvent | KeyboardEvent }
| { type: 'Execute keymap action'; data: InteractionMapItem }
| { type: 'Update prefix matrix' }
| { type: 'Add last interaction to sequence' }
| { type: 'Clear sequence' }
| { type: 'Resolve hotkey by prefix'; data: MouseEvent | KeyboardEvent }
| { type: 'done.invoke.resolveHotkeyByPrefix'; data: InteractionMapItem }
| {
type: 'error.platform.resolveHotkeyByPrefix'
data: string | undefined
},
},
id: 'Interaction Map Actor',
initial: 'Listening for interaction',
description:
'Manages the keymap of actions that can be take with the keyboard, mouse, or combination of the two while using the app.',
states: {
'Listening for interaction': {
on: {
'Fire event': {
target: 'Resolve hotkey',
},
},
},
'Resolve hotkey': {
invoke: {
id: 'resolveHotkeyByPrefix',
onDone: {
target: 'Execute keymap event',
},
onError: [
{
target: 'Listening for interaction',
actions: {
type: 'Add last interaction to sequence',
},
cond: {
type: 'There are prefix matches',
},
},
{
target: 'Listening for interaction',
actions: {
type: 'Clear sequence',
},
},
],
src: 'Resolve hotkey by prefix',
},
},
'Execute keymap event': {
exit: {
type: 'Clear sequence',
},
invoke: {
id: 'executeKeymapAction',
onDone: {
target: 'Listening for interaction',
},
onError: {
target: 'Listening for interaction',
},
src: 'Execute keymap action',
},
},
},
on: {
'Add to interaction map': {
target: '#Interaction Map Actor',
actions: [
{
type: 'Add to interactionMap',
},
],
},
'Remove from interaction map': {
target: '#Interaction Map Actor',
internal: true,
actions: [
{
type: 'Remove from interactionMap',
},
],
},
},
})