import { memo, useCallback, useMemo, useRef, useState } from 'react' import { useHotkeys } from 'react-hotkeys-hook' import { useAppState } from '@src/AppState' import { ActionButton } from '@src/components/ActionButton' import { ActionButtonDropdown } from '@src/components/ActionButtonDropdown' import { CustomIcon } from '@src/components/CustomIcon' import Tooltip from '@src/components/Tooltip' import { useModelingContext } from '@src/hooks/useModelingContext' import { useNetworkContext } from '@src/hooks/useNetworkContext' import { NetworkHealthState } from '@src/hooks/useNetworkStatus' import { useKclContext } from '@src/lang/KclProvider' import { isCursorInFunctionDefinition } from '@src/lang/queryAst' import { EngineConnectionStateType } from '@src/lang/std/engineConnection' import { isCursorInSketchCommandRange } from '@src/lang/util' import { isDesktop } from '@src/lib/isDesktop' import { openExternalBrowserIfDesktop } from '@src/lib/openWindow' import { editorManager, kclManager } from '@src/lib/singletons' import type { ToolbarDropdown, ToolbarItem, ToolbarItemCallbackProps, ToolbarItemResolved, ToolbarItemResolvedDropdown, ToolbarModeName, } from '@src/lib/toolbar' import { isToolbarItemResolvedDropdown, toolbarConfig } from '@src/lib/toolbar' import { commandBarActor } from '@src/lib/singletons' import { filterEscHotkey } from '@src/lib/hotkeyWrapper' export function Toolbar({ className = '', ...props }: React.HTMLAttributes) { const { state, send, context } = useModelingContext() const iconClassName = 'group-disabled:text-chalkboard-50 !text-inherit dark:group-enabled:group-hover:!text-inherit' const bgClassName = '!bg-transparent' const buttonBgClassName = 'bg-chalkboard-transparent dark:bg-transparent disabled:bg-transparent dark:disabled:bg-transparent enabled:hover:bg-chalkboard-10 dark:enabled:hover:bg-chalkboard-100 pressed:!bg-primary pressed:enabled:hover:!text-chalkboard-10' const buttonBorderClassName = '!border-transparent' const sketchPathId = useMemo(() => { if ( isCursorInFunctionDefinition( kclManager.ast, context.selectionRanges.graphSelections[0] ) ) return false return isCursorInSketchCommandRange( kclManager.artifactGraph, context.selectionRanges ) }, [kclManager.artifactGraph, context.selectionRanges]) const toolbarButtonsRef = useRef(null) const { overallState, immediateState } = useNetworkContext() const { isExecuting } = useKclContext() const { isStreamReady } = useAppState() const [showRichContent, setShowRichContent] = useState(false) const disableAllButtons = (overallState !== NetworkHealthState.Ok && overallState !== NetworkHealthState.Weak) || isExecuting || immediateState.type !== EngineConnectionStateType.ConnectionEstablished || !isStreamReady const currentMode = (Object.entries(toolbarConfig).find(([_, mode]) => mode.check(state) )?.[0] as ToolbarModeName) || 'modeling' /** These are the props that will be passed to the callbacks in the toolbar config * They are memoized to prevent unnecessary re-renders, * but they still get a lot of churn from the state machine * so I think there's a lot of room for improvement here */ const configCallbackProps: ToolbarItemCallbackProps = useMemo( () => ({ modelingState: state, modelingSend: send, sketchPathId, editorHasFocus: editorManager.editorView?.hasFocus, }), [ state, send, commandBarActor.send, sketchPathId, editorManager.editorView?.hasFocus, ] ) const tooltipContentClassName = !showRichContent ? '' : '!text-left text-wrap !text-xs !p-0 !pb-2 flex !max-w-none !w-72 flex-col items-stretch' const richContentTimeout = useRef(null) const richContentClearTimeout = useRef(null) // On mouse enter, show rich content after a 1s delay const handleMouseEnter = useCallback(() => { // Cancel the clear timeout if it's already set if (richContentClearTimeout.current) { clearTimeout(richContentClearTimeout.current) } // Start our own timeout to show the rich content richContentTimeout.current = window.setTimeout(() => { setShowRichContent(true) if (richContentClearTimeout.current) { clearTimeout(richContentClearTimeout.current) } }, 1000) }, [setShowRichContent]) // On mouse leave, clear the timeout and hide rich content const handleMouseLeave = useCallback(() => { // Clear the timeout to show rich content if (richContentTimeout.current) { clearTimeout(richContentTimeout.current) } // Start a timeout to hide the rich content richContentClearTimeout.current = window.setTimeout(() => { setShowRichContent(false) if (richContentClearTimeout.current) { clearTimeout(richContentClearTimeout.current) } }, 500) }, [setShowRichContent]) /** * Resolve all the callbacks and values for the current mode, * so we don't need to worry about the other modes */ const currentModeItems: ( | ToolbarItemResolved | ToolbarItemResolvedDropdown | 'break' )[] = useMemo(() => { return toolbarConfig[currentMode].items.map((maybeIconConfig) => { if (maybeIconConfig === 'break') { return 'break' } else if (isToolbarDropdown(maybeIconConfig)) { return { id: maybeIconConfig.id, array: maybeIconConfig.array.map((item) => resolveItemConfig(item)), } } else { return resolveItemConfig(maybeIconConfig) } }) function resolveItemConfig( maybeIconConfig: ToolbarItem ): ToolbarItemResolved { const isConfiguredAvailable = ['available', 'experimental'].includes( maybeIconConfig.status ) const isDisabled = disableAllButtons || !isConfiguredAvailable || maybeIconConfig.disabled?.(state) === true || kclManager.hasErrors() return { ...maybeIconConfig, title: typeof maybeIconConfig.title === 'string' ? maybeIconConfig.title : maybeIconConfig.title(configCallbackProps), description: maybeIconConfig.description, links: maybeIconConfig.links || [], isActive: maybeIconConfig.isActive?.(state), hotkey: typeof maybeIconConfig.hotkey === 'string' ? maybeIconConfig.hotkey : maybeIconConfig.hotkey?.(state), disabled: isDisabled, disabledReason: typeof maybeIconConfig.disabledReason === 'function' ? maybeIconConfig.disabledReason(state) : maybeIconConfig.disabledReason, disableHotkey: maybeIconConfig.disableHotkey?.(state), status: maybeIconConfig.status, } } }, [currentMode, disableAllButtons, configCallbackProps]) // To remember the last selected item in an ActionButtonDropdown const [lastSelectedMultiActionItem, _] = useState( new Map< number /* index in currentModeItems */, number /* index in maybeIconConfig */ >() ) return (
    {/* A menu item will either be a vertical line break, a button with a dropdown, or a single button */} {currentModeItems.map((maybeIconConfig, i) => { // Vertical Line Break if (maybeIconConfig === 'break') { return (
    ) } else if (isToolbarItemResolvedDropdown(maybeIconConfig)) { // A button with a dropdown const selectedIcon = maybeIconConfig.array.find((c) => c.isActive) || maybeIconConfig.array[lastSelectedMultiActionItem.get(i) ?? 0] // Save the last selected item in the dropdown lastSelectedMultiActionItem.set( i, maybeIconConfig.array.indexOf(selectedIcon) ) return ( ({ id: itemConfig.id, label: itemConfig.title, hotkey: itemConfig.hotkey, onClick: () => itemConfig.onClick(configCallbackProps), disabled: disableAllButtons || !['available', 'experimental'].includes( itemConfig.status ) || itemConfig.disabled === true || itemConfig.disableHotkey === true, status: itemConfig.status, }))} >
    selectedIcon.onClick(configCallbackProps)} > {selectedIcon.title} {showRichContent ? ( ) : ( )}
    ) } const itemConfig = maybeIconConfig // A single button return (
    itemConfig.onClick(configCallbackProps)} > {itemConfig.title} {showRichContent ? ( ) : ( )}
    ) })}
{state.matches('Sketch no face') && (

Select a plane or face to start sketching

)}
) } interface ToolbarItemContentsProps extends React.PropsWithChildren { itemConfig: ToolbarItemResolved configCallbackProps: ToolbarItemCallbackProps wrapperClassName?: string contentClassName?: string } /** * The single button and dropdown button share content, so we extract it here * It contains a tooltip with the title, description, and links * and a hotkey listener */ const ToolbarItemTooltip = memo(function ToolbarItemContents({ itemConfig, configCallbackProps, wrapperClassName = '', contentClassName = '', children, }: ToolbarItemContentsProps) { /** * GOTCHA: `useHotkeys` can only register one hotkey listener per component. * TODO: make a global hotkey registration system. make them editable. */ useHotkeys( itemConfig.hotkey || '', () => { itemConfig.onClick(configCallbackProps) }, { enabled: ['available', 'experimental'].includes(itemConfig.status) && !!itemConfig.hotkey && !itemConfig.disabled && !itemConfig.disableHotkey, } ) return ( {children} {kclManager.hasErrors() && (

Fix KCL errors to enable tools

)}
) }) const ToolbarItemTooltipShortContent = ({ status, title, hotkey, }: { status: string title: string hotkey?: string | string[] }) => (
{status === 'experimental' && (
Experimental
)}
{title} {hotkey && ( {filterEscHotkey(hotkey)} )}
) const ToolbarItemTooltipRichContent = ({ itemConfig, }: { itemConfig: ToolbarItemResolved }) => { const shouldBeEnabled = ['available', 'experimental'].includes( itemConfig.status ) const { state } = useModelingContext() return ( <> {itemConfig.status === 'experimental' && (
Experimental
)}
{itemConfig.icon && ( )}
{itemConfig.title}
{shouldBeEnabled && itemConfig.hotkey ? ( {filterEscHotkey(itemConfig.hotkey)} ) : itemConfig.status === 'kcl-only' ? ( <> KCL code only ) : ( itemConfig.status === 'unavailable' && ( <> In development ) )}

{itemConfig.description}

{/* Add disabled reason if item is disabled */} {itemConfig.disabled && itemConfig.disabledReason && ( <>

{typeof itemConfig.disabledReason === 'function' ? itemConfig.disabledReason(state) : itemConfig.disabledReason}

)} {itemConfig.links.length > 0 && ( <>
)} ) } function isToolbarDropdown( item: ToolbarItem | ToolbarDropdown ): item is ToolbarDropdown { return 'array' in item }