Files
website/src/lib/components/TerminalTUI.svelte
T
2025-12-01 05:18:27 +00:00

318 lines
8.6 KiB
Svelte

<script lang="ts">
import { onMount } from "svelte";
import { themeColors } from "$lib/stores/theme";
import {
terminalSettings,
type SpeedPreset,
speedPresets,
} from "$lib/config";
import TuiHeader from "./tui/TuiHeader.svelte";
import TuiBody from "./tui/TuiBody.svelte";
import TuiFooter from "./tui/TuiFooter.svelte";
import "$lib/assets/css/terminal-tui.css";
// Import extracted modules
import { parseLine, createTerminalAPI } from "./tui/terminal-api";
import {
runTypingAnimation,
skipTypingAnimation,
} from "./tui/terminal-typing";
import {
createKeyboardHandler,
handleNavigation,
scrollToHash,
} from "./tui/terminal-keyboard";
import type {
TerminalLine,
ParsedLine,
DisplayedLine,
TerminalAPI,
} from "./tui/types";
interface Props {
lines?: TerminalLine[];
title?: string;
class?: string;
onComplete?: () => void;
interactive?: boolean;
speed?: SpeedPreset | number;
autoscroll?: boolean;
terminal?: TerminalAPI;
}
let {
lines = $bindable([]),
title = "terminal",
class: className = "",
onComplete,
interactive = true,
speed = "normal",
autoscroll = true,
terminal = $bindable(),
}: Props = $props();
// Calculate speed multiplier from preset or number
const speedMultiplier = $derived(
typeof speed === "number" ? speed : (speedPresets[speed] ?? 1),
);
// Get colorMap from current theme
const colorMap = $derived($themeColors.colorMap);
// Pre-parse all lines upfront (segments + plain text)
const parsedLines = $derived<ParsedLine[]>(
lines.map((line) => parseLine(line, colorMap)),
);
let displayedLines = $state<DisplayedLine[]>([]);
let currentLineIndex = $state(0);
let isTyping = $state(false);
let isComplete = $state(false);
let selectedIndex = $state(-1);
let skipRequested = $state(false);
let terminalElement: HTMLDivElement;
let bodyElement = $state<HTMLDivElement>();
// Track colorMap identity to detect theme/mode changes
let lastColorMapId = $state("");
// When colorMap changes (theme/mode toggle), update displayedLines with new parsed segments
$effect(() => {
// Create a simple identity string from a few colorMap values
const colorMapId = `${colorMap.red}-${colorMap.text}-${colorMap.primary}`;
// Only update if colorMap actually changed and animation is complete
if (
colorMapId !== lastColorMapId &&
isComplete &&
displayedLines.length > 0
) {
// Store current showImage states before updating
const showImageStates = displayedLines.map((d) => d.showImage);
// Update with new parsed content
displayedLines = parsedLines.map((parsed, i) => ({
parsed,
charIndex: parsed.plainText.length,
complete: true,
showImage: showImageStates[i] ?? parsed.line.type === "image",
}));
}
lastColorMapId = colorMapId;
});
// Helper to check if a line or its children contain buttons
function hasButtons(line: TerminalLine): boolean {
if (line.type === "button") return true;
if (line.type === "group" && line.children) {
return line.children.some((child) => hasButtons(child));
}
return false;
}
// Get all interactive button indices (including buttons nested in groups)
const buttonIndices = $derived(
displayedLines
.map((item, i) => (hasButtons(item.parsed.line) ? i : -1))
.filter((i) => i !== -1),
);
// ========================================================================
// TYPING ANIMATION
// ========================================================================
function typeText() {
runTypingAnimation({
parsedLines,
speedMultiplier,
terminalSettings,
buttonIndices,
interactive,
autoscroll,
getBodyElement: () => bodyElement,
getState: () => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
setState: (updates) => {
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
onComplete,
scrollToHash: () => scrollToHash(bodyElement),
});
}
function skipAnimation() {
skipTypingAnimation(
parsedLines,
buttonIndices,
interactive,
() => ({
displayedLines,
currentLineIndex,
isTyping,
isComplete,
skipRequested,
selectedIndex,
}),
(updates) => {
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
},
() => bodyElement,
autoscroll,
onComplete,
);
}
// ========================================================================
// TERMINAL API
// ========================================================================
terminal = createTerminalAPI({
getState: () => ({
lines,
displayedLines,
isTyping,
isComplete,
currentLineIndex,
selectedIndex,
skipRequested,
}),
setState: (updates) => {
if (updates.lines !== undefined) lines = updates.lines;
if (updates.displayedLines !== undefined)
displayedLines = updates.displayedLines;
if (updates.isTyping !== undefined) isTyping = updates.isTyping;
if (updates.isComplete !== undefined)
isComplete = updates.isComplete;
if (updates.currentLineIndex !== undefined)
currentLineIndex = updates.currentLineIndex;
if (updates.selectedIndex !== undefined)
selectedIndex = updates.selectedIndex;
if (updates.skipRequested !== undefined)
skipRequested = updates.skipRequested;
},
getColorMap: () => colorMap,
getBodyElement: () => bodyElement,
typeText,
});
// ========================================================================
// KEYBOARD HANDLING
// ========================================================================
const handleKeydown = createKeyboardHandler({
getIsTyping: () => isTyping,
getIsComplete: () => isComplete,
getInteractive: () => interactive,
getButtonIndices: () => buttonIndices,
getSelectedIndex: () => selectedIndex,
setSelectedIndex: (index) => {
selectedIndex = index;
},
getDisplayedLines: () => displayedLines,
getBodyElement: () => bodyElement,
skipAnimation,
scrollMargin: terminalSettings.scrollMargin,
});
function handleButtonClick(index: number, line?: TerminalLine) {
if (line) {
// If line is provided (e.g. from nested group), use it directly
// We don't update selectedIndex because it might not map to a top-level line
handleNavigation(line as any);
} else {
// Fallback to top-level index lookup
selectedIndex = index;
handleNavigation(displayedLines[index]?.parsed.line as any);
}
}
function handleLinkClick(index: number) {
handleNavigation(displayedLines[index]?.parsed.line as any);
}
onMount(() => {
typeText();
});
</script>
<svelte:window on:keydown={handleKeydown} />
<div
class="tui-terminal {className}"
style="
--terminal-bg: {$themeColors.terminal};
--terminal-text: {$themeColors.text};
--terminal-muted: {$themeColors.textMuted};
--terminal-border: {$themeColors.border};
--terminal-prompt: {$themeColors.terminalPrompt};
--terminal-user: {$themeColors.terminalUser};
--terminal-path: {$themeColors.terminalPath};
--terminal-primary: {$themeColors.primary};
--terminal-secondary: {$themeColors.secondary};
--terminal-accent: {$themeColors.accent};
--terminal-bg-light: {$themeColors.backgroundLight};
"
bind:this={terminalElement}
role="region"
aria-label="Terminal interface"
>
<!-- Hyprland-style border glow -->
<div class="tui-border-glow"></div>
<!-- TUI Content -->
<div class="tui-content">
<TuiHeader
{title}
{interactive}
hasButtons={buttonIndices.length > 0}
/>
<!-- Main terminal area -->
<TuiBody
bind:ref={bodyElement}
{displayedLines}
{currentLineIndex}
{isTyping}
{selectedIndex}
onButtonClick={handleButtonClick}
onHoverButton={(i) => (selectedIndex = i)}
onLinkClick={handleLinkClick}
{terminalSettings}
/>
<TuiFooter
{isTyping}
linesCount={displayedLines.length}
{skipAnimation}
/>
</div>
</div>