242 lines
7.8 KiB
Svelte
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>
|