Files
website/src/lib/components/tui/TuiBody.svelte
T

314 lines
7.9 KiB
Svelte

<script lang="ts">
import { getSegmentsUpToChar } from "./utils";
import { user } from "$lib/config";
import TuiLine from "./TuiLine.svelte";
import type { DisplayedLine, TerminalLine } from "./types";
import "$lib/assets/css/tui-body.css";
interface Props {
displayedLines: DisplayedLine[];
currentLineIndex?: number;
isTyping?: boolean;
selectedIndex?: number;
ref?: HTMLDivElement | undefined;
onButtonClick: (idx: number, line?: TerminalLine) => void;
onHoverButton: (idx: number) => void;
onLinkClick: (idx: number) => void;
terminalSettings: { showCursor: boolean };
interactiveInput?: boolean;
onCommand?: (command: string) => void;
}
let {
displayedLines = [],
currentLineIndex = 0,
isTyping = false,
selectedIndex = -1,
ref = $bindable(undefined),
onButtonClick,
onHoverButton,
onLinkClick,
terminalSettings,
interactiveInput = false,
onCommand,
}: Props = $props();
let inputEl = $state<HTMLInputElement>();
let commandValue = $state("");
let history = $state<string[]>([]);
let historyIndex = $state(-1);
let inputFocused = $state(false);
let caretPos = $state(0);
const beforeText = $derived(commandValue.slice(0, caretPos));
const cursorChar = $derived(commandValue.slice(caretPos, caretPos + 1) || " ");
const afterRest = $derived(commandValue.slice(caretPos + 1));
const afterFull = $derived(commandValue.slice(caretPos));
function syncCaret() {
caretPos = inputEl?.selectionStart ?? commandValue.length;
}
function recall(value: string) {
commandValue = value;
caretPos = value.length;
}
function submitCommand() {
const value = commandValue;
commandValue = "";
caretPos = 0;
if (value.trim()) history = [...history, value];
historyIndex = -1;
onCommand?.(value);
queueMicrotask(() => inputEl?.focus());
}
function handleInputKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
submitCommand();
} else if (event.key === "ArrowUp") {
event.preventDefault();
event.stopPropagation();
if (history.length === 0) return;
const nextIndex =
historyIndex === -1
? history.length - 1
: Math.max(0, historyIndex - 1);
historyIndex = nextIndex;
recall(history[nextIndex]);
} else if (event.key === "ArrowDown") {
event.preventDefault();
event.stopPropagation();
if (historyIndex === -1) return;
const nextIndex = historyIndex + 1;
if (nextIndex >= history.length) {
historyIndex = -1;
recall("");
} else {
historyIndex = nextIndex;
recall(history[nextIndex]);
}
} else {
event.stopPropagation();
}
}
function focusCommandInput(event: MouseEvent) {
if (!interactiveInput) return;
const target = event.target as HTMLElement;
if (target.closest("a, button, input, textarea, .tui-button, .tui-link"))
return;
inputEl?.focus();
}
// Group consecutive inline items together
type ProcessedGroup =
| { kind: "single"; index: number; displayed: DisplayedLine }
| {
kind: "inline";
items: Array<{ index: number; displayed: DisplayedLine }>;
};
const processedGroups = $derived.by(() => {
const groups: ProcessedGroup[] = [];
let i = 0;
while (i < displayedLines.length) {
const displayed = displayedLines[i];
const isInline =
displayed.parsed.line.inline ||
displayed.parsed.line.display === "inline";
if (isInline) {
const inlineItems: Array<{
index: number;
displayed: DisplayedLine;
}> = [];
// Collect consecutive inline items
while (i < displayedLines.length) {
const nextLine = displayedLines[i].parsed.line;
const nextIsInline =
nextLine.inline || nextLine.display === "inline";
if (!nextIsInline) break;
inlineItems.push({
index: i,
displayed: displayedLines[i],
});
i++;
}
groups.push({ kind: "inline", items: inlineItems });
} else {
groups.push({ kind: "single", index: i, displayed });
i++;
}
}
return groups;
});
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="tui-body" bind:this={ref} onclick={focusCommandInput}>
{#each processedGroups as group, gi (gi)}
{#if group.kind === "inline"}
<div class="tui-inline-group">
{#each group.items as item (item.index)}
<TuiLine
line={item.displayed.parsed.line}
index={item.index}
segments={getSegmentsUpToChar(
item.displayed.parsed.segments,
item.displayed.charIndex,
)}
complete={item.displayed.complete}
showImage={item.displayed.showImage}
{selectedIndex}
inline={true}
showCursor={terminalSettings.showCursor &&
item.index === currentLineIndex &&
!item.displayed.complete &&
isTyping &&
item.displayed.parsed.line.type !== "image"}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/each}
</div>
{:else}
<TuiLine
line={group.displayed.parsed.line}
index={group.index}
segments={getSegmentsUpToChar(
group.displayed.parsed.segments,
group.displayed.charIndex,
)}
complete={group.displayed.complete}
showImage={group.displayed.showImage}
{selectedIndex}
inline={false}
showCursor={terminalSettings.showCursor &&
group.index === currentLineIndex &&
!group.displayed.complete &&
isTyping &&
group.displayed.parsed.line.type !== "image"}
{onButtonClick}
{onHoverButton}
{onLinkClick}
/>
{/if}
{/each}
{#if interactiveInput}
{#if !isTyping}
<div class="tui-line prompt tui-command-line">
<span class="prompt">
<span class="user">{user.username}</span><span class="at"
>@</span
><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span
class="symbol">$</span
>
</span>
<span class="tui-input-wrap">
<input
class="tui-command-input"
bind:this={inputEl}
bind:value={commandValue}
onkeydown={handleInputKeydown}
onkeyup={syncCaret}
oninput={syncCaret}
onclick={syncCaret}
onfocus={() => {
inputFocused = true;
syncCaret();
}}
onblur={() => (inputFocused = false)}
spellcheck="false"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
aria-label="Terminal command input"
/><span class="tui-input-mirror" aria-hidden="true">{beforeText}{#if inputFocused}<span class="cursor-block blink">{cursorChar}</span>{afterRest}{:else}{afterFull}{/if}</span>
</span>
</div>
{/if}
{:else if terminalSettings.showCursor && !isTyping && displayedLines.length > 0}
<div class="tui-line prompt">
<span class="prompt">
<span class="user">{user.username}</span><span class="at"
>@</span
><span class="host">{user.hostname}</span>
<span class="separator">:</span><span class="path">~</span><span
class="symbol">$</span
>
</span>
<span class="cursor blink"></span>
</div>
{/if}
</div>
<style>
.tui-command-line {
display: flex;
align-items: center;
}
.tui-input-wrap {
position: relative;
flex: 1;
min-width: 0;
margin-left: 0.4rem;
display: flex;
align-items: center;
}
.tui-command-input {
width: 100%;
margin: 0;
padding: 0;
background: transparent;
border: none;
outline: none;
box-shadow: none;
color: transparent;
font: inherit;
line-height: inherit;
caret-color: transparent;
}
.tui-command-input:focus,
.tui-command-input:focus-visible {
border: none;
outline: none;
box-shadow: none;
}
.tui-input-mirror {
position: absolute;
inset: 0;
display: flex;
align-items: center;
white-space: pre;
overflow: hidden;
pointer-events: none;
color: var(--terminal-text);
}
.cursor-block {
display: inline-block;
height: 1.2em;
line-height: 1.2em;
color: var(--terminal-bg);
background: var(--terminal-accent, var(--terminal-primary));
}
.cursor-block.blink {
animation: cursorBlink 1s step-end infinite;
}
</style>