Terminal UI API Update

This commit is contained in:
2025-11-29 03:53:21 +00:00
parent 22e02eb97b
commit 82d896a38e
13 changed files with 952 additions and 256 deletions
+148
View File
@@ -0,0 +1,148 @@
/**
* Terminal Keyboard Handler
*
* Handles keyboard navigation and interaction for the terminal.
*/
import type { DisplayedLine } from './types';
import { browser } from '$app/environment';
export interface KeyboardHandlerOptions {
getIsTyping: () => boolean;
getIsComplete: () => boolean;
getInteractive: () => boolean;
getButtonIndices: () => number[];
getSelectedIndex: () => number;
setSelectedIndex: (index: number) => void;
getDisplayedLines: () => DisplayedLine[];
getBodyElement: () => HTMLDivElement | undefined;
skipAnimation: () => void;
scrollMargin?: number;
}
/**
* Scroll selected button into view with margin for context
*/
export function scrollToSelected(
bodyElement: HTMLDivElement | undefined,
selectedIndex: number,
displayedLines: DisplayedLine[],
scrollMargin: number = 80
): void {
if (!bodyElement || selectedIndex < 0) return;
const buttons = bodyElement.querySelectorAll('.tui-button');
const btnIndices = displayedLines
.map((item, idx) => item.parsed.line.type === 'button' ? idx : -1)
.filter(idx => idx !== -1);
const selectedButton = Array.from(buttons).find((_, i) => {
return btnIndices[i] === selectedIndex;
}) as HTMLElement | undefined;
if (selectedButton && bodyElement) {
const containerRect = bodyElement.getBoundingClientRect();
const buttonRect = selectedButton.getBoundingClientRect();
if (buttonRect.top < containerRect.top + scrollMargin) {
const scrollAmount = buttonRect.top - containerRect.top - scrollMargin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
} else if (buttonRect.bottom > containerRect.bottom - scrollMargin) {
const scrollAmount = buttonRect.bottom - containerRect.bottom + scrollMargin;
bodyElement.scrollBy({ top: scrollAmount, behavior: 'smooth' });
}
}
}
/**
* Handle button/link click navigation
*/
export function handleNavigation(line: { action?: () => void; href?: string; external?: boolean } | undefined): void {
if (!line) return;
if (line.action) {
line.action();
} else if (line.href) {
const isExternal = line.external || line.href.startsWith('http://') || line.href.startsWith('https://');
if (isExternal) {
window.open(line.href, '_blank', 'noopener,noreferrer');
} else {
window.location.href = line.href;
}
}
}
/**
* Create a keyboard event handler for the terminal
*/
export function createKeyboardHandler(options: KeyboardHandlerOptions) {
const {
getIsTyping,
getIsComplete,
getInteractive,
getButtonIndices,
getSelectedIndex,
setSelectedIndex,
getDisplayedLines,
getBodyElement,
skipAnimation,
scrollMargin = 80
} = options;
return function handleKeydown(event: KeyboardEvent): void {
// Skip animation on Y key
if (getIsTyping() && (event.key === 'y' || event.key === 'Y')) {
event.preventDefault();
skipAnimation();
return;
}
if (!getInteractive() || !getIsComplete()) return;
const buttonIndices = getButtonIndices();
if (buttonIndices.length === 0) return;
const selectedIndex = getSelectedIndex();
const currentButtonIdx = buttonIndices.indexOf(selectedIndex);
const displayedLines = getDisplayedLines();
const bodyElement = getBodyElement();
if (event.key === 'ArrowDown' || event.key === 'j') {
event.preventDefault();
const nextIdx = (currentButtonIdx + 1) % buttonIndices.length;
const newSelectedIndex = buttonIndices[nextIdx];
setSelectedIndex(newSelectedIndex);
scrollToSelected(bodyElement, newSelectedIndex, displayedLines, scrollMargin);
} else if (event.key === 'ArrowUp' || event.key === 'k') {
event.preventDefault();
const prevIdx = (currentButtonIdx - 1 + buttonIndices.length) % buttonIndices.length;
const newSelectedIndex = buttonIndices[prevIdx];
setSelectedIndex(newSelectedIndex);
scrollToSelected(bodyElement, newSelectedIndex, displayedLines, scrollMargin);
} else if (event.key === 'Enter') {
event.preventDefault();
const selectedLine = displayedLines[selectedIndex]?.parsed.line;
handleNavigation(selectedLine);
}
};
}
/**
* Scroll to hash target (anchor link like #skills)
*/
export function scrollToHash(bodyElement: HTMLDivElement | undefined): void {
if (!browser || !bodyElement) return;
const hash = window.location.hash;
if (!hash) return;
const targetId = hash.slice(1); // Remove the #
const targetElement = bodyElement.querySelector(`#${CSS.escape(targetId)}`);
if (targetElement) {
// Small delay to ensure layout is complete
setTimeout(() => {
targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
}
}