import { Box, Text, useActionShortcut, useNativeRenderer, useUiCapabilities } from "../../../ui"; import { useCallback, useRef, type ReactNode } from "react"; import { blendHex, colors, floatingPaneTitleBg, paneTitleBg, paneTitleText } from "../../../theme/colors"; import { WEB_CELL_WIDTH } from "../../../theme/font-scale"; import { displayWidth, truncateToDisplayWidth } from "../../../utils/format"; import { capturePointerDrag } from "../../../ui/pointer-drag"; import { Tabs } from "../../ui/tabs"; import { Icon, IconButton } from "../../ui/icon"; import type { PaneHeaderTabsRegistration } from "./header-tabs"; import { nativePaneHeaderRows } from "./sizing"; const PANE_HEADER_HEIGHT = 1; /** Space above desktop title-bar tabs, in px. */ const HEADER_TAB_TOP_GAP = 2; const PANE_HEADER_GRIP = ":: "; export const PANE_HEADER_ACTION = " ... "; export const PANE_HEADER_CLOSE = " x "; export const PANE_HEADER_LOCK = " 🔒 "; interface PaneHeaderProps { title: string; width: number; focused: boolean; windowModeSelected?: boolean; floating?: boolean; locked?: boolean; showActions?: boolean; quickSettings?: PaneHeaderQuickSetting[]; /** Desktop only: the pane's primary tab strip, drawn as title-bar tabs. */ tabs?: PaneHeaderTabsRegistration | null; /** The body colour the active title-bar tab opens into. */ bodyBackground?: string; /** * Desktop: a 1px line is drawn over the header's first row (a dock divider * above it, or a floating pane's border). The header mirrors it with padding * so its contents centre between the visible lines. */ topRule?: boolean; /** Desktop only: the header is a popped-out window's title bar. */ titleBar?: PaneHeaderTitleBar | null; onHeaderMouseMove?: (event: any) => void; onHeaderMouseDown?: (event: any) => void; onHeaderMouseDrag?: (event: any) => void; onHeaderMouseDragEnd?: (event: any) => void; onHeaderContextMenu?: (event: any) => void; onActionMouseDown?: (event: any) => void; onCloseMouseDown?: (event: any) => void; } /** * A popped-out window has no title bar of its own: the pane header is it. The * window moves by it and closes by its own controls, so there is no grip and * no close button. */ interface PaneHeaderTitleBar { /** Height in cells. */ rows: number; /** Sits in the window's title-bar overlay, beside the macOS traffic lights. */ overlay: boolean; /** Cells kept clear before the title for the traffic lights. */ leadingInset: number; /** Drawn after the pane actions: the Windows window buttons. */ trailing?: ReactNode; } export interface PaneHeaderQuickSetting { key: string; icon: "zap"; label: string; description?: string; active: boolean; onMouseDown?: (event: any) => void; } function truncateTitle(title: string, maxWidth: number): string { return truncateToDisplayWidth(title, maxWidth); } function TerminalPaneButton({ text, fg, role, onMouseDown, }: { text: string; fg: string; role: string; onMouseDown?: (event: any) => void; }) { return ( {text} ); } export function PaneHeader({ title, width, focused, windowModeSelected = false, floating = false, locked = false, showActions = false, quickSettings = [], tabs = null, bodyBackground, topRule = false, titleBar = null, onHeaderMouseMove, onHeaderMouseDown, onHeaderMouseDrag, onHeaderMouseDragEnd, onHeaderContextMenu, onActionMouseDown, onCloseMouseDown, }: PaneHeaderProps) { const { nativePaneChrome } = useUiCapabilities(); const nativeRenderer = useNativeRenderer(); const menuShortcut = useActionShortcut("pane-menu"); const closeShortcut = useActionShortcut("pane-close"); const terminalHeaderRef = useRef(null); const visuallyFocused = focused || windowModeSelected; const backgroundColor = floating ? floatingPaneTitleBg(visuallyFocused) : paneTitleBg(visuallyFocused); const actionText = showActions ? PANE_HEADER_ACTION : " "; const closeText = floating ? PANE_HEADER_CLOSE : ""; const lockText = locked ? PANE_HEADER_LOCK : ""; const terminalQuickSettingsWidth = quickSettings.reduce((total) => total + displayWidth(" ⚡ "), 0) + displayWidth(lockText); const textColor = paneTitleText(visuallyFocused, floating); const topInset = topRule ? 1 : 0; const handleTerminalHeaderMouseDown = useCallback((event: any) => { capturePointerDrag(nativeRenderer, terminalHeaderRef.current); onHeaderMouseDown?.(event); }, [nativeRenderer, onHeaderMouseDown]); if (nativePaneChrome) { const ruleColor = visuallyFocused ? colors.borderFocused : colors.border; return ( {/* Part of the header, so it clicks and drags like the rest of it. */} {!titleBar && ( )} {/* Full header height, so trimming the title to its capitals never lets this clip cut descenders. */} {title} {tabs && ( { // Tabs select on click; the header must not start a pane drag. event.stopPropagation?.(); }} style={{ alignSelf: "stretch", marginTop: -topInset, marginBottom: -1, marginLeft: 10, "--pane-tab-top-gap": `${HEADER_TAB_TOP_GAP}px`, // Undoes the negative top margin for controls centred in the // strip (the overflow chevron). "--pane-tab-top-inset": `${topInset}px`, // Lifts tab labels onto the header's centre line. The tab spans // gap..bottom with a 1px top border; the centre line sits halfway // between the top rule (if any) and the bottom rule. "--pane-tab-label-lift": `${HEADER_TAB_TOP_GAP + 2 - topInset}px`, // Fallback until the tab strip has measured the surface under it. "--pane-tab-active-bg": bodyBackground ?? colors.bg, "--pane-tab-border": ruleColor, // The active tab reads as active whenever the pane is focused, even // while a detail or field inside the pane owns the keyboard. "--pane-tab-active-fg": visuallyFocused ? colors.textBright : colors.text, "--pane-tab-hover-bg": blendHex(backgroundColor, colors.textBright, 0.06), }} > )} {quickSettings.map((setting) => ( setting.onMouseDown?.(event) : undefined} /> ))} {locked && ( )} {showActions ? ( onActionMouseDown(event) : undefined} /> ) : } {floating && !titleBar && ( onCloseMouseDown(event) : undefined} /> )} {titleBar?.trailing} ); } if (visuallyFocused || floating) { // Build: ┌─:: Title ─────────── ... x─┐ // Reserve 2 for corners, 1 for ─ after ┌, 1 for ─ before ┐ const borderColor = visuallyFocused ? colors.borderFocused : colors.border; const innerWidth = Math.max(0, width - 4); const contentWidth = PANE_HEADER_GRIP.length + terminalQuickSettingsWidth + closeText.length + actionText.length; const titleWidth = Math.max(0, innerWidth - contentWidth); const clippedTitle = truncateTitle(title, titleWidth); const fillLen = Math.max(0, innerWidth - PANE_HEADER_GRIP.length - displayWidth(clippedTitle) - terminalQuickSettingsWidth - actionText.length - closeText.length); const fill = "─".repeat(fillLen); return ( {"┌─"} {`${PANE_HEADER_GRIP}${clippedTitle}`} {quickSettings.map((setting) => ( ))} {fill} {locked && ( )} {floating && ( )} {"─┐"} ); } const titleWidth = Math.max(0, width - PANE_HEADER_GRIP.length - terminalQuickSettingsWidth - actionText.length - closeText.length); const clippedTitle = truncateTitle(title, titleWidth); const padding = " ".repeat(Math.max(0, titleWidth - displayWidth(clippedTitle))); return ( {`${PANE_HEADER_GRIP}${clippedTitle}${padding}`} {quickSettings.map((setting) => ( ))} {locked && ( )} {floating && ( )} ); }