318 lines
8.6 KiB
Svelte
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>
|