import { useRef, useMemo, memo, useCallback, useState } from 'react' import { isCursorInSketchCommandRange } from 'lang/util' import { editorManager, kclManager } from 'lib/singletons' import { useModelingContext } from 'hooks/useModelingContext' import { useNetworkContext } from 'hooks/useNetworkContext' import { NetworkHealthState } from 'hooks/useNetworkStatus' import { ActionButton } from 'components/ActionButton' import { useKclContext } from 'lang/KclProvider' import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { useHotkeys } from 'react-hotkeys-hook' import Tooltip from 'components/Tooltip' import { useAppState } from 'AppState' import { CustomIcon } from 'components/CustomIcon' import { toolbarConfig, ToolbarItem, ToolbarItemCallbackProps, ToolbarItemResolved, ToolbarModeName, } from 'lib/toolbar' import { isDesktop } from 'lib/isDesktop' import { openExternalBrowserIfDesktop } from 'lib/openWindow' import { isCursorInFunctionDefinition } from 'lang/queryAst' import { commandBarActor } from 'machines/commandBarMachine' import { isArray } from 'lib/utils' 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 } = useNetworkContext() const { isExecuting } = useKclContext() const { isStreamReady } = useAppState() const [showRichContent, setShowRichContent] = useState(false) const disableAllButtons = (overallState !== NetworkHealthState.Ok && overallState !== NetworkHealthState.Weak) || isExecuting || !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 gap-2 !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 | ToolbarItemResolved[] | 'break' )[] = useMemo(() => { return toolbarConfig[currentMode].items.map((maybeIconConfig) => { if (maybeIconConfig === 'break') { return 'break' } else if (isArray(maybeIconConfig)) { return maybeIconConfig.map(resolveItemConfig) } else { return resolveItemConfig(maybeIconConfig) } }) function resolveItemConfig( maybeIconConfig: ToolbarItem ): ToolbarItemResolved { const isDisabled = disableAllButtons || maybeIconConfig.status !== 'available' || maybeIconConfig.disabled?.(state) === true 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]) 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 (isArray(maybeIconConfig)) { // A button with a dropdown return ( ({ id: itemConfig.id, label: itemConfig.title, hotkey: itemConfig.hotkey, onClick: () => itemConfig.onClick(configCallbackProps), disabled: disableAllButtons || itemConfig.status !== 'available' || itemConfig.disabled === true, status: itemConfig.status, }))} >
    maybeIconConfig[0].onClick(configCallbackProps) } > {maybeIconConfig[0].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) { useHotkeys( itemConfig.hotkey || '', () => { itemConfig.onClick(configCallbackProps) }, { enabled: itemConfig.status === 'available' && !!itemConfig.hotkey && !itemConfig.disabled && !itemConfig.disableHotkey, } ) return ( {children} ) }) const ToolbarItemTooltipShortContent = ({ status, title, hotkey, }: { status: string title: string hotkey?: string | string[] }) => ( {title} {hotkey && ( {hotkey} )} ) const ToolbarItemTooltipRichContent = ({ itemConfig, }: { itemConfig: ToolbarItemResolved }) => { const { state } = useModelingContext() return ( <>
{itemConfig.icon && ( )} {itemConfig.title} {itemConfig.status === 'available' && itemConfig.hotkey ? ( {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 && ( <>
)} ) }