import { Box, Input, SpinnerMark, Text, TextAttributes, useActionShortcut, useCommandBarShortcut, useRendererHost, useUiCapabilities, type InputRenderable, } from "../../ui"; import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; import { blendHex, commandBarBg, commandBarPanelBg, commandBarSubtleText, commandBarText, } from "../../theme/colors"; import { useThemeColors } from "../../theme/theme-context"; import { NATIVE_COMMAND_SURFACE, nativeCommandSurfaceBorder } from "../command-bar/panel/native-surface"; import { useCommandBarPromptBinding, type CommandBarPromptBinding, } from "../command-bar/panel/prompt-binding"; import { useAppDispatch, useAppSelector } from "../../state/app/context"; import { selectCommandBarOpen, selectUpdateAvailable, selectUpdateCheckInProgress, selectUpdateNotice, selectUpdateProgress, } from "../../state/selectors-ui"; import { useViewport } from "../../react/input"; import { canRetryUpdate } from "../../app/global-shortcuts"; import { t, tf } from "../../i18n"; import { truncateToDisplayWidth } from "../../utils/format"; import { resolveMarketSummaryFit, useMarketSummary } from "./market-summary"; import { resolveHeaderPromptGeometry } from "./shell/chrome"; import { useWindowFullscreen } from "./window-fullscreen"; import { WindowControls, WINDOWS_CONTROL_GROUP_WIDTH_PX } from "./window-controls"; import { Button } from "../ui/button"; const UPDATE_NOTICE_DURATION_MS = 5_000; type HeaderActionEvent = { key?: string; preventDefault?: () => void; stopPropagation?: () => void; }; /** * Chooses what the prompt can say at a given width. The descriptive label earns * its space before the shortcut hint does, because the prompt is what tells a * first-time user the command bar exists at all. */ function resolveHeaderPromptContent(width: number, shortcutLabel: string): { placeholder: string; shortcut: string; } { const textSpace = Math.max(0, width - 2 - "> ".length); const full = t("Search or run a command"); const short = t("Search"); if (textSpace >= full.length + shortcutLabel.length + 2) return { placeholder: full, shortcut: shortcutLabel }; if (textSpace >= full.length) return { placeholder: full, shortcut: "" }; if (textSpace >= short.length) return { placeholder: short, shortcut: "" }; return { placeholder: "", shortcut: "" }; } const PROMPT_CARET = "> "; /** * The topbar's own fill in the terminal. The prompt used to be the only thing * wearing it, which left the bar reading as a pale input pasted onto a darker * strip; now the whole row carries it and the prompt paints nothing of its own. * Desktop has real chrome to sit on and uses the header colour directly. */ function headerSurface(colors: ReturnType): string { return blendHex(colors.header, colors.bg, 0.55); } /** * Desktop chrome for the prompt. Closed it draws nothing: no fill and no edge, * so the caret and its placeholder sit straight on the header instead of in a * pale box pasted onto it. The hover fill is the whole affordance, and the * border stays in the box model as a transparent hairline so opening the bar * cannot shift the text by a pixel. Open it is the top half of the command * surface: same fill, same border and same shadow as the sheet, rounded only * where it is not touching it, and stretched to the header's full height so the * two meet with no gap. Both boxes take their left edge and width from * `resolveHeaderPromptGeometry`, so the seam is invisible rather than nearly * invisible. */ function nativePromptSurfaceStyle(colors: ReturnType, open: boolean) { if (!open) { return { border: "1px solid transparent", borderRadius: 5, }; } const { radiusPx, shadow } = NATIVE_COMMAND_SURFACE; return { alignSelf: "stretch", height: "100%", border: `1px solid ${nativeCommandSurfaceBorder(colors)}`, borderBottomWidth: 0, borderRadius: `${radiusPx}px ${radiusPx}px 0 0`, boxShadow: shadow, }; } /** * The command bar's input while the bar is open. Gloomberb's panes are driven * by bare single keys, so the input only exists while the bar is open; the idle * prompt is an affordance that never takes keyboard focus. */ function HeaderPromptInput({ binding, nativePaneChrome, width, }: { binding: CommandBarPromptBinding; nativePaneChrome: boolean; width: number; }) { const colors = useThemeColors(); const inputRef = useRef(null); const { ghostSuffix, onQueryChange, placeholder, query, screenKey } = binding; const textColor = commandBarText(colors); const subtleColor = commandBarSubtleText(colors); // The buffer is the source of truth while typing; only an outside change, // such as Ctrl+U or a popped route restoring its query, has to be written back. useLayoutEffect(() => { const input = inputRef.current; if (!input || input.editBuffer.getText() === query) return; input.editBuffer.setText?.(query); input.setCursorOffset?.(query.length); }, [query, screenKey]); const ghostLeft = Math.max(0, Math.min(query.length, width - 1)); return ( {ghostSuffix && ( {truncateToDisplayWidth(ghostSuffix, Math.max(0, width - ghostLeft))} )} ); } function HeaderCommandPrompt({ nativePaneChrome, onOpen, open, shortcutLabel, width, }: { nativePaneChrome: boolean; onOpen: (event?: HeaderActionEvent) => void; open: boolean; shortcutLabel: string; width: number; }) { const colors = useThemeColors(); const binding = useCommandBarPromptBinding(); const { placeholder, shortcut } = resolveHeaderPromptContent(width, shortcutLabel); const idleBg = headerSurface(colors); // Open, the prompt takes the sheet's own surface so the two read as one // control: the sheet is the prompt, expanded. Closed it fills nothing on // either host; the terminal row is already this colour end to end, and on // desktop the header is. const backgroundColor = open ? (nativePaneChrome ? commandBarPanelBg(colors) : commandBarBg(colors)) : undefined; const caretColor = open ? commandBarText(colors) : blendHex(colors.headerText, colors.header, 0.15); const mutedColor = blendHex(colors.headerText, colors.header, 0.42); // Dimmer than the placeholder it trails: the label is what names the control, // the binding is a footnote to it. const shortcutColor = blendHex(colors.headerText, colors.header, 0.62); // Desktop hover lifts the prompt off the header itself, which is the only // thing telling the pointer this text is a control. The terminal has no // chrome, so it lifts off the strip fill the whole row already wears. const hoverBg = nativePaneChrome ? blendHex(colors.header, colors.headerText, 0.14) : blendHex(idleBg, colors.headerText, 0.16); const inputWidth = Math.max(1, width - 2 - PROMPT_CARET.length); return ( { if (event.key === "Enter" || event.key === " ") onOpen(event); }} style={{ cursor: open ? undefined : "pointer", ...(nativePaneChrome ? nativePromptSurfaceStyle(colors, open) : {}), }} > {PROMPT_CARET} {open ? ( binding ? : null ) : ( <> {placeholder} {shortcut ? {` ${shortcut}`} : null} )} ); } function UpdateStatus() { const colors = useThemeColors(); const dispatch = useAppDispatch(); const updateAvailable = useAppSelector(selectUpdateAvailable); const updateProgress = useAppSelector(selectUpdateProgress); const updateCheckInProgress = useAppSelector(selectUpdateCheckInProgress); const updateNotice = useAppSelector(selectUpdateNotice); const retryKey = useActionShortcut("install-update"); useEffect(() => { if (!updateNotice || updateAvailable || updateProgress || updateCheckInProgress) return; const timeout = setTimeout(() => { dispatch({ type: "SET_UPDATE_NOTICE", notice: null }); }, UPDATE_NOTICE_DURATION_MS); return () => clearTimeout(timeout); }, [dispatch, updateAvailable, updateCheckInProgress, updateNotice, updateProgress]); if (updateProgress) { if (updateProgress.phase === "downloading") { return ( {tf("Downloading v{version}: {percent}%", { version: updateAvailable?.version ?? "", percent: updateProgress.percent ?? 0, })} ); } if (updateProgress.phase === "replacing") { return ( {t("Installing update...")} ); } if (updateProgress.phase === "done") { return {t(updateProgress.message ?? "Update installed, restart to apply")}; } if (updateProgress.phase === "error") { const retry = retryKey && canRetryUpdate({ updateAvailable, updateProgress, updateCheckInProgress }); return ( {tf("Update failed: {error}", { error: updateProgress.error ?? "Unknown error" })} {retry ? {` ${retryKey} ${t("retry")}`} : null} ); } } if (updateCheckInProgress) { return ( {t("Checking for updates...")} ); } if (updateAvailable) { if (updateAvailable.updateAction.kind === "manual") { return ( {tf("v{version} available — run {command}", { version: updateAvailable.version, command: updateAvailable.updateAction.command, })} ); } if (updateAvailable.updateAction.kind === "managed") { return ( {tf("v{version} available, update through the package manager that installed Gloomberb", { version: updateAvailable.version, })} ); } return ( {tf("v{version} available — starting download...", { version: updateAvailable.version })} ); } if (updateNotice) { return {updateNotice}; } return null; } /** * Market state, SPY and the base currency at the header's right edge. It fits * itself into the columns the prompt geometry reserved rather than into * whatever this row has left over, so the cluster and the prompt can never * both claim the same space. */ function HeaderMarketSummary({ nativePaneChrome, width }: { nativePaneChrome: boolean; width: number }) { const colors = useThemeColors(); const summary = useMarketSummary(); const fit = resolveMarketSummaryFit({ available: width, baseCurrencyWidth: summary.baseCurrency.length + 1, countdownWidth: summary.marketLabel.length - summary.marketLabelShort.length, spyWidth: summary.spyText.length + 1, stateWidth: summary.marketLabelShort ? summary.marketLabelShort.length + 1 : 0, }); const marketLabel = fit.showCountdown ? summary.marketLabel : summary.marketLabelShort; return ( <> {fit.showState && marketLabel ? ( {marketLabel} ) : null} {fit.showSpy ? ( {summary.spyText} ) : null} {fit.showBaseCurrency ? ( {summary.baseCurrency} ) : null} ); } export function Header({ onOpenHelp, }: { onOpenHelp?: () => void; }) { const colors = useThemeColors(); const rendererHost = useRendererHost(); const dispatch = useAppDispatch(); const commandBarOpen = useAppSelector(selectCommandBarOpen); const { width: termWidth } = useViewport(); const { nativePaneChrome = false, titleBarOverlay, nativeWindowChrome = titleBarOverlay, windowControls } = useUiCapabilities(); const showWindowControls = nativeWindowChrome && windowControls === "windows"; const windowFullscreen = useWindowFullscreen(); const prompt = resolveHeaderPromptGeometry({ nativePaneChrome, nativeWindowChrome, termWidth, titleBarOverlay, windowFullscreen, }); const shortcutLabel = useCommandBarShortcut(); // Desktop has its own window chrome to sit on; the terminal has none, so the // bar itself wears the prompt's fill and reads as a single strip. const headerBg = nativePaneChrome ? colors.header : headerSurface(colors); const startWindowDrag = useCallback(() => { if (!titleBarOverlay || !nativeWindowChrome) return; void rendererHost.startWindowDrag?.(); }, [nativeWindowChrome, rendererHost, titleBarOverlay]); const openCommandBar = useCallback((event?: HeaderActionEvent) => { event?.preventDefault?.(); event?.stopPropagation?.(); dispatch({ type: "SET_COMMAND_BAR", open: true, query: "" }); }, [dispatch]); const commandPrompt = ( ); const marketSummary = prompt.marketColumns > 0 ? : null; if (titleBarOverlay) { return ( {commandPrompt} {onOpenHelp ? (