314 lines
7.9 KiB
Svelte
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>
|