import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { Box, Text, useNativeRenderer, useUiHost, type BoxRenderable, type InputRenderable, } from "../../../ui"; import { InlineQuickAddRow, ListView, MenuPopover, type ListViewItem } from "../../../components/ui"; import { usePaneMenuItems } from "../../../components"; import { getNativeSurfaceManager } from "../../../components/chart/native/surface/manager"; import { getRenderableCellRect } from "../../../components/chart/native/surface/visibility"; import { useShortcut } from "../../../react/input"; import { isPlainKey } from "../../../utils/keyboard"; import { useAppInputCapture } from "../../../state/app/input-capture"; import { useOptionalPaneInstanceId } from "../../../state/app/context"; import { colors } from "../../../theme/colors"; import type { ChartSpec } from "../../../time-series/types"; import { getSharedRegistry } from "../../registry"; import { MAX_CHART_COMPOSER_SERIES } from "./chart-spec"; import { appendChartSeries } from "./presets"; import type { SeriesCatalogInstrument, SeriesCatalogSuggestion } from "./series-catalog"; import { useSeriesCatalogSuggestions } from "./use-series-catalog"; const MAX_VISIBLE_SUGGESTIONS = 4; const CHART_TOOLBAR_HEIGHT = 1; const MINIMUM_CHART_HEIGHT = 4; const QUICK_ADD_ROW_HEIGHT = 1; const IDLE_QUICK_ADD_WIDTH = 14; const ACTIVE_QUICK_ADD_WIDTH = 36; interface ClosestElementLike { closest(selector: string): { getAttribute(name: string): string | null; } | null; } /** * Blurs the field itself rather than going through the imperative handle, which * only reaches the element it still owns. A no-op unless this widget truly holds * the document focus, so a click inside a dialog leaves that dialog alone. */ export function releaseQuickAddFocus(quickAddId: string): void { const active = (globalThis as { document?: { activeElement?: { blur?: () => void } | null }; }).document?.activeElement; if (!active || !isChartQuickAddMouseTarget(active, quickAddId)) return; active.blur?.(); } export function isChartQuickAddMouseTarget(target: unknown, quickAddId: string): boolean { if (!target || typeof target !== "object") return false; const candidate = target as Partial & { parentElement?: Partial | null; }; const element = typeof candidate.closest === "function" ? candidate : candidate.parentElement; if (!element || typeof element.closest !== "function") return false; const root = element.closest("[data-gloom-chart-quick-add]"); return root?.getAttribute("data-gloom-chart-quick-add") === quickAddId; } function clampSelection(index: number, length: number): number { if (length <= 0) return -1; return Math.max(0, Math.min(index, length - 1)); } function defaultCatalogInstrument(spec: ChartSpec): SeriesCatalogInstrument { const security = spec.series.find((series) => series.source.kind === "security"); const instrument = security?.source.kind === "security" ? security.source.instrument : undefined; const symbol = instrument?.symbol ?? "AAPL"; const registry = getSharedRegistry(); const saved = typeof registry?.getTickerFn === "function" ? registry.getTickerFn(symbol) : undefined; return { symbol, ...(instrument?.exchange ? { exchange: instrument.exchange } : saved?.metadata.exchange ? { exchange: saved.metadata.exchange } : {}), ...(saved?.metadata.name ? { name: saved.metadata.name } : {}), }; } export function ChartSeriesQuickAdd({ spec, setSpec, focused, width, height, shortcutEnabled, shortcutBlocked, onActivatePane, onActiveChange, onWidthChange, }: { spec: ChartSpec; setSpec: (spec: ChartSpec) => void; focused: boolean; width: number; height: number; shortcutEnabled: boolean; shortcutBlocked: boolean; onActivatePane: () => void; onActiveChange?: (active: boolean) => void; onWidthChange?: (width: number) => void; }) { const ui = useUiHost(); const nativeRenderer = useNativeRenderer(); const paneId = useOptionalPaneInstanceId(); const quickAddId = useId(); const inputRef = useRef(null); const drawerRef = useRef(null); const commitLockRef = useRef(false); const blurTimerRef = useRef | null>(null); const [active, setActive] = useState(false); const [inputFocused, setInputFocused] = useState(false); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [error, setError] = useState(null); const defaultInstrument = useMemo(() => defaultCatalogInstrument(spec), [spec]); const { suggestions, loading, error: searchError } = useSeriesCatalogSuggestions({ query, defaultInstrument, enabled: active, }); const controlWidth = active ? Math.max(8, Math.min(ACTIVE_QUICK_ADD_WIDTH, width)) : Math.max(8, Math.min(IDLE_QUICK_ADD_WIDTH, width)); const drawerStatus = error ?? (loading && suggestions.length === 0 ? "Searching instruments..." // A failed lookup is not zero matches, so it keeps its own message. : searchError && suggestions.length === 0 ? searchError : active && query.trim().length > 0 && suggestions.length === 0 ? "No matching security or metric." : null); const maximumDrawerHeight = Math.max( 0, Math.min( MAX_VISIBLE_SUGGESTIONS, height - CHART_TOOLBAR_HEIGHT - MINIMUM_CHART_HEIGHT - QUICK_ADD_ROW_HEIGHT, ), ); const drawerHeight = active ? Math.min(maximumDrawerHeight, drawerStatus ? 1 : suggestions.length) : 0; const nativeSurfaceManager = useMemo( () => getNativeSurfaceManager(nativeRenderer), [nativeRenderer], ); useAppInputCapture(inputFocused && focused); useEffect(() => { const occluderId = `${quickAddId}:drawer`; if (ui.kind === "desktop-web" || drawerHeight <= 0 || !paneId || !drawerRef.current) { nativeSurfaceManager.removeLocalOccluder(occluderId); return; } const drawer = drawerRef.current as BoxRenderable & { onLifecyclePass?: (() => void) | null }; const previousLifecyclePass = drawer.onLifecyclePass; const syncOccluder = () => { if ( typeof drawer.x !== "number" || typeof drawer.y !== "number" || typeof drawer.width !== "number" || typeof drawer.height !== "number" ) { return; } nativeSurfaceManager.upsertLocalOccluder({ id: occluderId, paneId, rect: getRenderableCellRect(drawer as Parameters[0]), }); }; const lifecyclePass = () => { previousLifecyclePass?.(); syncOccluder(); }; drawer.onLifecyclePass = lifecyclePass; nativeRenderer.registerLifecyclePass(drawer); syncOccluder(); const mountTimer = setTimeout(syncOccluder, 0); return () => { clearTimeout(mountTimer); nativeRenderer.unregisterLifecyclePass(drawer); if (drawer.onLifecyclePass === lifecyclePass) drawer.onLifecyclePass = previousLifecyclePass; nativeSurfaceManager.removeLocalOccluder(occluderId); }; }, [drawerHeight, nativeRenderer, nativeSurfaceManager, paneId, quickAddId, ui.kind]); useEffect(() => { onActiveChange?.(inputFocused && focused); }, [focused, inputFocused, onActiveChange]); useEffect(() => { onWidthChange?.(controlWidth); }, [controlWidth, onWidthChange]); useEffect(() => { setSelectedIndex(0); }, [query]); // Plugin catalogs land after the securities: only pull a stranded cursor // back into range, so a row the user already moved to stays selected. useEffect(() => { setSelectedIndex((current) => clampSelection(current, suggestions.length)); }, [suggestions.length]); useEffect(() => { if (focused || !active) return; setActive(false); setInputFocused(false); inputRef.current?.blur?.(); }, [active, focused]); useEffect(() => { if (ui.kind !== "desktop-web") return; // Watch regardless of what state believes: the field can hold the document // focus after the state that tracks it has already moved on. const handleOutsideMouseDown = (event: globalThis.MouseEvent) => { if (isChartQuickAddMouseTarget(event.target, quickAddId)) return; releaseQuickAddFocus(quickAddId); }; document.addEventListener("mousedown", handleOutsideMouseDown, true); return () => document.removeEventListener("mousedown", handleOutsideMouseDown, true); }, [quickAddId, ui.kind]); const cancelPendingBlur = useCallback(() => { if (blurTimerRef.current === null) return; clearTimeout(blurTimerRef.current); blurTimerRef.current = null; }, []); useEffect(() => cancelPendingBlur, [cancelPendingBlur]); // Writing the field programmatically comes back through onChange, which is // otherwise indistinguishable from a keystroke and re-arms what just closed. const clearingRef = useRef(false); const clearInput = useCallback(() => { clearingRef.current = true; setQuery(""); inputRef.current?.editBuffer.setText?.(""); inputRef.current?.setCursorOffset?.(0); queueMicrotask(() => { clearingRef.current = false; }); }, []); const focusInput = useCallback(() => { cancelPendingBlur(); onActivatePane(); setActive(true); setInputFocused(true); onActiveChange?.(true); setError(null); queueMicrotask(() => inputRef.current?.focus?.()); }, [cancelPendingBlur, onActivatePane, onActiveChange]); const commitSuggestion = useCallback((suggestion: SeriesCatalogSuggestion | undefined) => { if (!suggestion || commitLockRef.current) return; cancelPendingBlur(); if (spec.series.length >= MAX_CHART_COMPOSER_SERIES) { setError(`Charts support up to ${MAX_CHART_COMPOSER_SERIES} base series.`); return; } commitLockRef.current = true; queueMicrotask(() => { commitLockRef.current = false; }); setSpec(appendChartSeries(spec, suggestion.expression).spec); clearInput(); setSelectedIndex(0); setError(null); setActive(false); setInputFocused(false); onActiveChange?.(false); queueMicrotask(() => inputRef.current?.blur?.()); }, [cancelPendingBlur, clearInput, onActiveChange, setSpec, spec]); const submit = useCallback(() => { commitSuggestion(suggestions[clampSelection(selectedIndex, suggestions.length)]); }, [commitSuggestion, selectedIndex, suggestions]); const cancel = useCallback(() => { cancelPendingBlur(); inputRef.current?.blur?.(); clearInput(); setActive(false); setInputFocused(false); onActiveChange?.(false); setError(null); }, [cancelPendingBlur, clearInput, onActiveChange]); useShortcut((event) => { if (!focused) return; if (!active) { if (isPlainKey(event, "n") && event.targetEditable !== true) { event.preventDefault?.(); event.stopPropagation?.(); focusInput(); } return; } if (event.name === "escape") { event.preventDefault?.(); event.stopPropagation?.(); cancel(); } else if (event.name === "up") { event.preventDefault?.(); event.stopPropagation?.(); setSelectedIndex((current) => clampSelection(current - 1, suggestions.length)); } else if (event.name === "down") { event.preventDefault?.(); event.stopPropagation?.(); setSelectedIndex((current) => clampSelection(current + 1, suggestions.length)); } }, { phase: "before", allowEditable: true, enabled: focused && !shortcutBlocked && (shortcutEnabled || active), }); // The legend row shows only its placeholder, so the pane menu is where `n` shows. usePaneMenuItems(`chart-series-quick-add:${quickAddId}`, () => ( shortcutEnabled && !active ? [{ id: "add-series", label: "Add Series", accelerator: "n", onSelect: focusInput }] : null ), [active, focusInput, shortcutEnabled]); const desktop = ui.kind === "desktop-web"; const quickAddRow = ( { setQuery(value); setError(null); if (clearingRef.current) return; if (!active) setActive(true); if (!inputFocused) setInputFocused(true); }} onSubmit={submit} onFocus={() => { cancelPendingBlur(); setInputFocused(true); }} onBlur={() => { setInputFocused(false); onActiveChange?.(false); cancelPendingBlur(); blurTimerRef.current = setTimeout(() => { blurTimerRef.current = null; clearInput(); setSelectedIndex(0); setError(null); setActive(false); }, 0); }} onCancel={cancel} /> ); const items = useMemo(() => suggestions.map((suggestion) => ({ id: suggestion.id, label: suggestion.label, description: suggestion.description, detail: suggestion.detail, })), [suggestions]); return ( {desktop ? ( // Desktop suggestions are the kit menu in the kit popover, like every // other dropdown; the input keeps focus and drives the highlight. 0} onOpenChange={(open) => { if (!open) cancel(); }} trigger={quickAddRow} focusOnOpen={false} label="Chart series suggestions" minWidth={controlWidth * 8} closeOnSelect={false} highlightedId={suggestions[clampSelection(selectedIndex, suggestions.length)]?.id ?? null} onHighlight={(id) => setSelectedIndex(Math.max(0, suggestions.findIndex((suggestion) => suggestion.id === id)))} items={drawerStatus ? [{ id: "\u0000status", kind: "heading", label: drawerStatus }] : suggestions.map((suggestion) => ({ id: suggestion.id, label: suggestion.label, description: suggestion.description, hint: suggestion.detail, }))} onSelect={(id) => commitSuggestion(suggestions.find((suggestion) => suggestion.id === id))} /> ) : quickAddRow} {!desktop && drawerHeight > 0 ? ( void; stopPropagation?: () => void; }) => { cancelPendingBlur(); event.preventDefault?.(); event.stopPropagation?.(); }} > {drawerStatus ? ( {drawerStatus} ) : ( drawerHeight} rowGap={0} selectOnHover onSelect={setSelectedIndex} onActivate={(_, index) => commitSuggestion(suggestions[index])} remoteLabel="Chart series suggestions" /> )} ) : null} ); }