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 && (
)}
);
}