Files
website/src/lib/components/TerminalTUI.svelte
T
2025-11-29 03:53:21 +00:00

242 lines
7.8 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;
});
// Get all interactive button indices
const buttonIndices = $derived(
displayedLines
.map((item, i) => item.parsed.line.type === 'button' ? 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) {
selectedIndex = index;
handleNavigation(displayedLines[index]?.parsed.line);
}
function handleLinkClick(index: number) {
handleNavigation(displayedLines[index]?.parsed.line);
}
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={terminalSettings}
/>
<TuiFooter isTyping={isTyping} linesCount={displayedLines.length} skipAnimation={skipAnimation} />
</div>
</div>