TUI Renderer Update
This commit is contained in:
35
README.md
35
README.md
@@ -364,6 +364,28 @@ Supported inline types: `button`, `link`, `tooltip`, `progress`, `output`, `info
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Group
|
||||||
|
|
||||||
|
Groups allow you to arrange multiple elements together with custom layout:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'row', // row | column (default: row)
|
||||||
|
groupAlign: 'start', // start | center | end
|
||||||
|
groupGap: '1rem', // CSS gap value
|
||||||
|
inline: true, // Render inline with other elements
|
||||||
|
children: [
|
||||||
|
{ type: 'output', content: 'Label:', inline: true },
|
||||||
|
{ type: 'button', content: 'Action', style: 'primary', inline: true },
|
||||||
|
{ type: 'link', content: 'More info', href: '/help', inline: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Groups support nested groups and all element types as children. Children are rendered using the same `TuiLine` component, ensuring consistent behavior.
|
||||||
|
|
||||||
## TUI Components
|
## TUI Components
|
||||||
|
|
||||||
### TerminalTUI
|
### TerminalTUI
|
||||||
@@ -411,15 +433,24 @@ src/lib/components/
|
|||||||
├── terminal-typing.ts # Typing animation engine
|
├── terminal-typing.ts # Typing animation engine
|
||||||
├── terminal-keyboard.ts# Keyboard navigation handler
|
├── terminal-keyboard.ts# Keyboard navigation handler
|
||||||
├── TuiHeader.svelte # Top status bar
|
├── TuiHeader.svelte # Top status bar
|
||||||
├── TuiBody.svelte # Scrollable content area
|
├── TuiBody.svelte # Scrollable content area (uses TuiLine)
|
||||||
├── TuiFooter.svelte # Bottom status bar
|
├── TuiFooter.svelte # Bottom status bar
|
||||||
|
├── TuiLine.svelte # Unified line renderer for all types
|
||||||
|
├── TuiGroup.svelte # Container for grouped elements
|
||||||
├── TuiButton.svelte # Full-width button
|
├── TuiButton.svelte # Full-width button
|
||||||
├── TuiLink.svelte # Inline clickable link
|
├── TuiLink.svelte # Inline clickable link
|
||||||
├── TuiCard.svelte # Card with header/body/footer
|
├── TuiCard.svelte # Card with header/body/footer
|
||||||
|
├── TuiCardGrid.svelte # Grid layout for cards
|
||||||
├── TuiProgress.svelte # Animated progress bar
|
├── TuiProgress.svelte # Animated progress bar
|
||||||
├── TuiAccordion.svelte # Collapsible sections
|
├── TuiAccordion.svelte # Collapsible sections
|
||||||
├── TuiTable.svelte # Data table with headers
|
├── TuiTable.svelte # Data table with headers
|
||||||
└── TuiTooltip.svelte # Hover tooltip
|
├── TuiTooltip.svelte # Hover tooltip
|
||||||
|
├── TuiInput.svelte # Text input field
|
||||||
|
├── TuiTextarea.svelte # Multi-line text input
|
||||||
|
├── TuiCheckbox.svelte # Checkbox input
|
||||||
|
├── TuiRadio.svelte # Radio button group
|
||||||
|
├── TuiSelect.svelte # Dropdown select
|
||||||
|
└── TuiToggle.svelte # Toggle switch
|
||||||
```
|
```
|
||||||
|
|
||||||
## Terminal API
|
## Terminal API
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host",
|
"dev": "vite dev --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"start": "vite build && node server/server.js"
|
"server": "bun server/server.js",
|
||||||
|
"start": "vite build && bun server/server.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.4.0",
|
"@sveltejs/adapter-node": "^5.4.0",
|
||||||
|
|||||||
@@ -1,22 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import { getSegmentsUpToChar } from './utils';
|
||||||
import { getSegmentStyle, getLinePrefix, getSegmentsUpToChar } from './utils';
|
|
||||||
import { user } from '$lib/config';
|
import { user } from '$lib/config';
|
||||||
import TuiButton from './TuiButton.svelte';
|
import TuiLine from './TuiLine.svelte';
|
||||||
import TuiLink from './TuiLink.svelte';
|
|
||||||
import TuiCard from './TuiCard.svelte';
|
|
||||||
import TuiProgress from './TuiProgress.svelte';
|
|
||||||
import TuiAccordion from './TuiAccordion.svelte';
|
|
||||||
import TuiTable from './TuiTable.svelte';
|
|
||||||
import TuiTooltip from './TuiTooltip.svelte';
|
|
||||||
import TuiCardGrid from './TuiCardGrid.svelte';
|
|
||||||
import TuiInput from './TuiInput.svelte';
|
|
||||||
import TuiTextarea from './TuiTextarea.svelte';
|
|
||||||
import TuiCheckbox from './TuiCheckbox.svelte';
|
|
||||||
import TuiRadio from './TuiRadio.svelte';
|
|
||||||
import TuiSelect from './TuiSelect.svelte';
|
|
||||||
import TuiToggle from './TuiToggle.svelte';
|
|
||||||
import TuiGroup from './TuiGroup.svelte';
|
|
||||||
import type { DisplayedLine } from './types';
|
import type { DisplayedLine } from './types';
|
||||||
import '$lib/assets/css/tui-body.css';
|
import '$lib/assets/css/tui-body.css';
|
||||||
|
|
||||||
@@ -28,169 +13,68 @@
|
|||||||
export let onButtonClick: (idx: number) => void;
|
export let onButtonClick: (idx: number) => void;
|
||||||
export let onHoverButton: (idx: number) => void;
|
export let onHoverButton: (idx: number) => void;
|
||||||
export let onLinkClick: (idx: number) => void;
|
export let onLinkClick: (idx: number) => void;
|
||||||
export let terminalSettings: any;
|
export let terminalSettings: { showCursor: boolean };
|
||||||
|
|
||||||
// Group displayed lines into regular items and inline groups
|
// Group consecutive inline items together
|
||||||
type GroupedItem =
|
type ProcessedGroup =
|
||||||
| { type: 'single'; index: number; displayed: DisplayedLine }
|
| { kind: 'single'; index: number; displayed: DisplayedLine }
|
||||||
| { type: 'inline-group'; indices: number[]; items: DisplayedLine[] };
|
| { kind: 'inline'; items: Array<{ index: number; displayed: DisplayedLine }> };
|
||||||
|
|
||||||
$: groupedLines = (() => {
|
$: processedGroups = (() => {
|
||||||
const result: GroupedItem[] = [];
|
const groups: ProcessedGroup[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < displayedLines.length) {
|
while (i < displayedLines.length) {
|
||||||
const displayed = displayedLines[i];
|
const displayed = displayedLines[i];
|
||||||
const line = displayed.parsed.line;
|
if (displayed.parsed.line.inline) {
|
||||||
|
const inlineItems: Array<{ index: number; displayed: DisplayedLine }> = [];
|
||||||
if (line.inline) {
|
|
||||||
// Start an inline group
|
|
||||||
const group: GroupedItem = { type: 'inline-group', indices: [], items: [] };
|
|
||||||
while (i < displayedLines.length && displayedLines[i].parsed.line.inline) {
|
while (i < displayedLines.length && displayedLines[i].parsed.line.inline) {
|
||||||
group.indices.push(i);
|
inlineItems.push({ index: i, displayed: displayedLines[i] });
|
||||||
group.items.push(displayedLines[i]);
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
result.push(group);
|
groups.push({ kind: 'inline', items: inlineItems });
|
||||||
} else {
|
} else {
|
||||||
result.push({ type: 'single', index: i, displayed });
|
groups.push({ kind: 'single', index: i, displayed });
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return groups;
|
||||||
return result;
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="tui-body" bind:this={ref}>
|
<div class="tui-body" bind:this={ref}>
|
||||||
{#each groupedLines as group}
|
{#each processedGroups as group, gi (gi)}
|
||||||
{#if group.type === 'inline-group'}
|
{#if group.kind === 'inline'}
|
||||||
<!-- Inline group container -->
|
|
||||||
<div class="tui-inline-group">
|
<div class="tui-inline-group">
|
||||||
{#each group.items as displayed, j}
|
{#each group.items as item (item.index)}
|
||||||
{@const line = displayed.parsed.line}
|
<TuiLine
|
||||||
{@const idx = group.indices[j]}
|
line={item.displayed.parsed.line}
|
||||||
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
|
index={item.index}
|
||||||
{@const showImage = displayed.showImage}
|
segments={getSegmentsUpToChar(item.displayed.parsed.segments, item.displayed.charIndex)}
|
||||||
{#if line.type === 'button'}
|
complete={item.displayed.complete}
|
||||||
<TuiButton {line} index={idx} selected={selectedIndex === idx} onClick={onButtonClick} onHover={onHoverButton} inline={true} />
|
showImage={item.displayed.showImage}
|
||||||
{:else if line.type === 'link'}
|
{selectedIndex}
|
||||||
<TuiLink {line} onClick={() => onLinkClick(idx)} />
|
inline={true}
|
||||||
{:else if line.type === 'tooltip'}
|
{onButtonClick}
|
||||||
<TuiTooltip {line} />
|
{onHoverButton}
|
||||||
{:else if line.type === 'progress'}
|
{onLinkClick}
|
||||||
<TuiProgress {line} inline={true} />
|
/>
|
||||||
{:else if line.type === 'input'}
|
|
||||||
<TuiInput {line} inline={true} />
|
|
||||||
{:else if line.type === 'checkbox'}
|
|
||||||
<TuiCheckbox {line} inline={true} />
|
|
||||||
{:else if line.type === 'toggle'}
|
|
||||||
<TuiToggle {line} inline={true} />
|
|
||||||
{:else if line.type === 'group'}
|
|
||||||
<TuiGroup {line} inline={true} {onButtonClick} {onHoverButton} {onLinkClick} />
|
|
||||||
{:else if line.type === 'image' && showImage}
|
|
||||||
<div class="tui-image inline-image">
|
|
||||||
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
|
||||||
{#if line.content}
|
|
||||||
<span class="image-caption">{line.content}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if line.type !== 'image'}
|
|
||||||
<span class="inline-content {line.type}">
|
|
||||||
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if group.type === 'single'}
|
{:else}
|
||||||
{@const i = group.index}
|
<TuiLine
|
||||||
{@const displayed = group.displayed}
|
line={group.displayed.parsed.line}
|
||||||
{@const line = displayed.parsed.line}
|
index={group.index}
|
||||||
{@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)}
|
segments={getSegmentsUpToChar(group.displayed.parsed.segments, group.displayed.charIndex)}
|
||||||
{@const complete = displayed.complete}
|
complete={group.displayed.complete}
|
||||||
{@const showImage = displayed.showImage}
|
showImage={group.displayed.showImage}
|
||||||
{#if line.type === 'divider'}
|
{selectedIndex}
|
||||||
<div class="tui-divider" id={line.id}>
|
inline={false}
|
||||||
<span class="divider-line"></span>
|
showCursor={terminalSettings.showCursor && group.index === currentLineIndex && !group.displayed.complete && isTyping && group.displayed.parsed.line.type !== 'image'}
|
||||||
{#if line.content}
|
{onButtonClick}
|
||||||
<span class="divider-text">{line.content}</span>
|
{onHoverButton}
|
||||||
{/if}
|
{onLinkClick}
|
||||||
<span class="divider-line"></span>
|
/>
|
||||||
</div>
|
|
||||||
{:else if line.type === 'button'}
|
|
||||||
<TuiButton {line} index={i} selected={selectedIndex === i} onClick={onButtonClick} onHover={onHoverButton} />
|
|
||||||
{:else if line.type === 'link'}
|
|
||||||
<div class="tui-line link">
|
|
||||||
<TuiLink {line} onClick={() => onLinkClick(i)} />
|
|
||||||
</div>
|
|
||||||
{:else if line.type === 'card'}
|
|
||||||
<TuiCard {line} />
|
|
||||||
{:else if line.type === 'cardgrid'}
|
|
||||||
<TuiCardGrid {line} />
|
|
||||||
{:else if line.type === 'progress'}
|
|
||||||
<TuiProgress {line} />
|
|
||||||
{:else if line.type === 'accordion'}
|
|
||||||
<TuiAccordion {line} />
|
|
||||||
{:else if line.type === 'table'}
|
|
||||||
<TuiTable {line} />
|
|
||||||
{:else if line.type === 'tooltip'}
|
|
||||||
<div class="tui-line">
|
|
||||||
<TuiTooltip {line} />
|
|
||||||
</div>
|
|
||||||
{:else if line.type === 'input'}
|
|
||||||
<TuiInput {line} />
|
|
||||||
{:else if line.type === 'textarea'}
|
|
||||||
<TuiTextarea {line} />
|
|
||||||
{:else if line.type === 'checkbox'}
|
|
||||||
<TuiCheckbox {line} />
|
|
||||||
{:else if line.type === 'radio'}
|
|
||||||
<TuiRadio {line} />
|
|
||||||
{:else if line.type === 'select'}
|
|
||||||
<TuiSelect {line} />
|
|
||||||
{:else if line.type === 'toggle'}
|
|
||||||
<TuiToggle {line} />
|
|
||||||
{:else if line.type === 'group'}
|
|
||||||
<TuiGroup {line} {onButtonClick} {onHoverButton} {onLinkClick} />
|
|
||||||
{:else}
|
|
||||||
<div class="tui-line {line.type}" class:complete id={line.id}>
|
|
||||||
{#if line.type === 'command' || line.type === '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>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if line.type === 'image' && showImage}
|
|
||||||
<div class="tui-image">
|
|
||||||
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
|
||||||
{#if line.content}
|
|
||||||
<span class="image-caption">{line.content}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if line.type === 'header'}
|
|
||||||
<span class="content header-text">
|
|
||||||
<Icon icon="mdi:pound" width="14" class="header-icon" />
|
|
||||||
{#each visibleSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width="16" class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{:else if line.type !== 'blank'}
|
|
||||||
<span class="content">
|
|
||||||
{getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'}
|
|
||||||
<span class="cursor"></span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '@iconify/svelte';
|
import { parseColorText, getPlainText } from './utils';
|
||||||
import { getSegmentStyle, getLinePrefix, getSegmentsUpToChar, parseColorText, getPlainText } from './utils';
|
|
||||||
import { handleNavigation } from './terminal-keyboard';
|
|
||||||
import { user } from '$lib/config';
|
|
||||||
import { themeColors } from '$lib/stores/theme';
|
import { themeColors } from '$lib/stores/theme';
|
||||||
import TuiButton from './TuiButton.svelte';
|
import TuiLine from './TuiLine.svelte';
|
||||||
import TuiLink from './TuiLink.svelte';
|
|
||||||
import TuiProgress from './TuiProgress.svelte';
|
|
||||||
import TuiTooltip from './TuiTooltip.svelte';
|
|
||||||
import TuiInput from './TuiInput.svelte';
|
|
||||||
import TuiCheckbox from './TuiCheckbox.svelte';
|
|
||||||
import TuiToggle from './TuiToggle.svelte';
|
|
||||||
import TuiGroup from './TuiGroup.svelte';
|
|
||||||
import type { TerminalLine } from './types';
|
import type { TerminalLine } from './types';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -37,107 +27,37 @@
|
|||||||
const parsedChildren = $derived(
|
const parsedChildren = $derived(
|
||||||
(line.children || []).map(child => {
|
(line.children || []).map(child => {
|
||||||
const segments = parseColorText(child.content, colorMap);
|
const segments = parseColorText(child.content, colorMap);
|
||||||
return {
|
return { line: child, segments, plainText: getPlainText(segments) };
|
||||||
line: child,
|
|
||||||
segments,
|
|
||||||
plainText: getPlainText(segments)
|
|
||||||
};
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Style for the group container
|
// Style for the group container
|
||||||
const groupStyle = $derived(() => {
|
const groupStyle = $derived(() => {
|
||||||
const styles: string[] = [];
|
const styles: string[] = [];
|
||||||
if (line.groupDirection === 'column') {
|
if (line.groupDirection === 'column') styles.push('flex-direction: column');
|
||||||
styles.push('flex-direction: column');
|
|
||||||
}
|
|
||||||
if (line.groupAlign) {
|
if (line.groupAlign) {
|
||||||
const alignMap = { start: 'flex-start', center: 'center', end: 'flex-end' };
|
const alignMap: Record<string, string> = { start: 'flex-start', center: 'center', end: 'flex-end' };
|
||||||
styles.push(`align-items: ${alignMap[line.groupAlign] || 'flex-start'}`);
|
styles.push(`align-items: ${alignMap[line.groupAlign] || 'flex-start'}`);
|
||||||
}
|
}
|
||||||
if (line.groupGap) {
|
if (line.groupGap) styles.push(`gap: ${line.groupGap}`);
|
||||||
styles.push(`gap: ${line.groupGap}`);
|
|
||||||
}
|
|
||||||
return styles.join('; ');
|
return styles.join('; ');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div class="tui-group" class:inline style={groupStyle()} id={line.id}>
|
||||||
class="tui-group"
|
{#each parsedChildren as parsed, idx (idx)}
|
||||||
class:inline
|
<TuiLine
|
||||||
style={groupStyle()}
|
line={parsed.line}
|
||||||
id={line.id}
|
index={idx}
|
||||||
>
|
segments={parsed.segments}
|
||||||
{#each parsedChildren as parsed, idx}
|
complete={true}
|
||||||
{@const child = parsed.line}
|
showImage={parsed.line.type === 'image'}
|
||||||
{@const visibleSegments = parsed.segments}
|
selectedIndex={-1}
|
||||||
{@const childInline = child.inline !== false}
|
inline={parsed.line.inline !== false}
|
||||||
|
{onButtonClick}
|
||||||
{#if child.type === 'image'}
|
{onHoverButton}
|
||||||
<div class="tui-image" class:inline-image={childInline}>
|
{onLinkClick}
|
||||||
<img
|
/>
|
||||||
src={child.image}
|
|
||||||
alt={child.imageAlt || 'Image'}
|
|
||||||
style="max-width: {child.imageWidth || 300}px"
|
|
||||||
/>
|
|
||||||
{#if child.content}
|
|
||||||
<span class="image-caption">{child.content}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{:else if child.type === 'button'}
|
|
||||||
<TuiButton line={child} index={idx} selected={false} onClick={() => handleNavigation(child)} onHover={() => {}} inline={childInline} />
|
|
||||||
{:else if child.type === 'link'}
|
|
||||||
<TuiLink line={child} onClick={() => onLinkClick(idx)} />
|
|
||||||
{:else if child.type === 'tooltip'}
|
|
||||||
<TuiTooltip line={child} />
|
|
||||||
{:else if child.type === 'progress'}
|
|
||||||
<TuiProgress line={child} inline={childInline} />
|
|
||||||
{:else if child.type === 'input'}
|
|
||||||
<TuiInput line={child} inline={childInline} />
|
|
||||||
{:else if child.type === 'checkbox'}
|
|
||||||
<TuiCheckbox line={child} inline={childInline} />
|
|
||||||
{:else if child.type === 'toggle'}
|
|
||||||
<TuiToggle line={child} inline={childInline} />
|
|
||||||
{:else if child.type === 'group'}
|
|
||||||
<TuiGroup line={child} inline={childInline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
|
||||||
{:else if child.type === 'header'}
|
|
||||||
<span class="content header-text">
|
|
||||||
<Icon icon="mdi:pound" width="14" class="header-icon" />
|
|
||||||
{#each visibleSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width="16" class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
{:else if child.type === 'blank'}
|
|
||||||
<!-- Empty for blank -->
|
|
||||||
{:else if child.type === 'command' || child.type === 'prompt'}
|
|
||||||
<span class="group-line {child.type}">
|
|
||||||
<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="content">
|
|
||||||
{#each visibleSegments as segment}
|
|
||||||
{#if segment.icon}
|
|
||||||
<Icon icon={segment.icon} width="14" class="inline-icon" />
|
|
||||||
{:else if getSegmentStyle(segment)}
|
|
||||||
<span style={getSegmentStyle(segment)}>{segment.text}</span>
|
|
||||||
{:else}
|
|
||||||
{segment.text}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
<span class="group-line {child.type}">
|
|
||||||
{getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}<Icon icon={segment.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(segment)}<span style={getSegmentStyle(segment)}>{segment.text}</span>{:else}{segment.text}{/if}{/each}
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -154,117 +74,15 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group-line {
|
|
||||||
display: block;
|
|
||||||
line-height: 1.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.output {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.info {
|
|
||||||
color: var(--terminal-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.success {
|
|
||||||
color: #a6e3a1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.error {
|
|
||||||
color: #f38ba8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.warning {
|
|
||||||
color: #f9e2af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group-line.command,
|
|
||||||
.group-line.prompt {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt {
|
|
||||||
display: inline-flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .user {
|
|
||||||
color: var(--terminal-user);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .at {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .host {
|
|
||||||
color: var(--terminal-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .separator {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .path {
|
|
||||||
color: var(--terminal-path);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prompt .symbol {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-text {
|
|
||||||
color: var(--terminal-accent);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-image {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tui-image img {
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid var(--terminal-border);
|
|
||||||
background: var(--terminal-bg-light);
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-caption {
|
|
||||||
color: var(--terminal-muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes lineSlideIn {
|
@keyframes lineSlideIn {
|
||||||
from {
|
from { opacity: 0; transform: translateX(-5px); }
|
||||||
opacity: 0;
|
to { opacity: 1; transform: translateX(0); }
|
||||||
transform: translateX(-5px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: inline groups become vertical stacked */
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tui-group.inline {
|
.tui-group.inline {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tui-group.inline .tui-image img {
|
|
||||||
max-width: 100% !important;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
138
src/lib/components/tui/TuiLine.svelte
Normal file
138
src/lib/components/tui/TuiLine.svelte
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Icon from '@iconify/svelte';
|
||||||
|
import { getSegmentStyle, getLinePrefix } from './utils';
|
||||||
|
import { handleNavigation } from './terminal-keyboard';
|
||||||
|
import { user } from '$lib/config';
|
||||||
|
import TuiButton from './TuiButton.svelte';
|
||||||
|
import TuiLink from './TuiLink.svelte';
|
||||||
|
import TuiCard from './TuiCard.svelte';
|
||||||
|
import TuiProgress from './TuiProgress.svelte';
|
||||||
|
import TuiAccordion from './TuiAccordion.svelte';
|
||||||
|
import TuiTable from './TuiTable.svelte';
|
||||||
|
import TuiTooltip from './TuiTooltip.svelte';
|
||||||
|
import TuiCardGrid from './TuiCardGrid.svelte';
|
||||||
|
import TuiInput from './TuiInput.svelte';
|
||||||
|
import TuiTextarea from './TuiTextarea.svelte';
|
||||||
|
import TuiCheckbox from './TuiCheckbox.svelte';
|
||||||
|
import TuiRadio from './TuiRadio.svelte';
|
||||||
|
import TuiSelect from './TuiSelect.svelte';
|
||||||
|
import TuiToggle from './TuiToggle.svelte';
|
||||||
|
import TuiGroup from './TuiGroup.svelte';
|
||||||
|
import type { TerminalLine, TextSegment } from './types';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
line: TerminalLine;
|
||||||
|
index: number;
|
||||||
|
segments: TextSegment[];
|
||||||
|
complete?: boolean;
|
||||||
|
showImage?: boolean;
|
||||||
|
selectedIndex?: number;
|
||||||
|
inline?: boolean;
|
||||||
|
showCursor?: boolean;
|
||||||
|
onButtonClick?: (idx: number) => void;
|
||||||
|
onHoverButton?: (idx: number) => void;
|
||||||
|
onLinkClick?: (idx: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
line,
|
||||||
|
index,
|
||||||
|
segments,
|
||||||
|
complete = true,
|
||||||
|
showImage = false,
|
||||||
|
selectedIndex = -1,
|
||||||
|
inline = false,
|
||||||
|
showCursor = false,
|
||||||
|
onButtonClick = () => {},
|
||||||
|
onHoverButton = () => {},
|
||||||
|
onLinkClick = () => {}
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
// Component types that have their own wrapper
|
||||||
|
const componentTypes = new Set(['button', 'card', 'cardgrid', 'progress', 'accordion', 'table', 'input', 'textarea', 'checkbox', 'radio', 'select', 'toggle', 'group']);
|
||||||
|
|
||||||
|
// Types that need special handling
|
||||||
|
const isComponent = $derived(componentTypes.has(line.type));
|
||||||
|
const isBlank = $derived(line.type === 'blank');
|
||||||
|
const isDivider = $derived(line.type === 'divider');
|
||||||
|
const isImage = $derived(line.type === 'image');
|
||||||
|
const isPrompt = $derived(line.type === 'command' || line.type === 'prompt');
|
||||||
|
const isHeader = $derived(line.type === 'header');
|
||||||
|
const isLink = $derived(line.type === 'link');
|
||||||
|
const isTooltip = $derived(line.type === 'tooltip');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if isBlank}
|
||||||
|
{#if inline}<span class="inline-blank"></span>{:else}<div class="tui-line blank"></div>{/if}
|
||||||
|
{:else if isDivider}
|
||||||
|
<div class="tui-divider" id={line.id}>
|
||||||
|
<span class="divider-line"></span>
|
||||||
|
{#if line.content}<span class="divider-text">{line.content}</span>{/if}
|
||||||
|
<span class="divider-line"></span>
|
||||||
|
</div>
|
||||||
|
{:else if line.type === 'button'}
|
||||||
|
<TuiButton {line} {index} selected={selectedIndex === index} onClick={onButtonClick} onHover={onHoverButton} {inline} />
|
||||||
|
{:else if isLink}
|
||||||
|
{#if inline}
|
||||||
|
<TuiLink {line} onClick={() => handleNavigation(line)} />
|
||||||
|
{:else}
|
||||||
|
<div class="tui-line link"><TuiLink {line} onClick={() => onLinkClick(index)} /></div>
|
||||||
|
{/if}
|
||||||
|
{:else if isTooltip}
|
||||||
|
{#if inline}<TuiTooltip {line} />{:else}<div class="tui-line"><TuiTooltip {line} /></div>{/if}
|
||||||
|
{:else if line.type === 'card'}
|
||||||
|
<TuiCard {line} />
|
||||||
|
{:else if line.type === 'cardgrid'}
|
||||||
|
<TuiCardGrid {line} />
|
||||||
|
{:else if line.type === 'progress'}
|
||||||
|
<TuiProgress {line} {inline} />
|
||||||
|
{:else if line.type === 'accordion'}
|
||||||
|
<TuiAccordion {line} />
|
||||||
|
{:else if line.type === 'table'}
|
||||||
|
<TuiTable {line} />
|
||||||
|
{:else if line.type === 'input'}
|
||||||
|
<TuiInput {line} {inline} />
|
||||||
|
{:else if line.type === 'textarea'}
|
||||||
|
<TuiTextarea {line} />
|
||||||
|
{:else if line.type === 'checkbox'}
|
||||||
|
<TuiCheckbox {line} {inline} />
|
||||||
|
{:else if line.type === 'radio'}
|
||||||
|
<TuiRadio {line} />
|
||||||
|
{:else if line.type === 'select'}
|
||||||
|
<TuiSelect {line} />
|
||||||
|
{:else if line.type === 'toggle'}
|
||||||
|
<TuiToggle {line} {inline} />
|
||||||
|
{:else if line.type === 'group'}
|
||||||
|
<TuiGroup {line} {inline} {onButtonClick} {onHoverButton} {onLinkClick} />
|
||||||
|
{:else if isImage && showImage}
|
||||||
|
<div class="tui-image" class:inline-image={inline}>
|
||||||
|
<img src={line.image} alt={line.imageAlt || 'Image'} style="max-width: {line.imageWidth || 300}px" />
|
||||||
|
{#if line.content}<span class="image-caption">{line.content}</span>{/if}
|
||||||
|
</div>
|
||||||
|
{:else if !isImage}
|
||||||
|
{#if inline}
|
||||||
|
<span class="inline-content {line.type}">
|
||||||
|
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon icon={seg.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(seg)}<span style={getSegmentStyle(seg)}>{seg.text}</span>{:else}{seg.text}{/if}{/each}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<div class="tui-line {line.type}" class:complete id={line.id}>
|
||||||
|
{#if isPrompt}
|
||||||
|
<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>
|
||||||
|
{/if}
|
||||||
|
{#if isHeader}
|
||||||
|
<span class="content header-text">
|
||||||
|
<Icon icon="mdi:pound" width="14" class="header-icon" />
|
||||||
|
{#each segments as seg}{#if seg.icon}<Icon icon={seg.icon} width="16" class="inline-icon" />{:else if getSegmentStyle(seg)}<span style={getSegmentStyle(seg)}>{seg.text}</span>{:else}{seg.text}{/if}{/each}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="content">
|
||||||
|
{getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}<Icon icon={seg.icon} width="14" class="inline-icon" />{:else if getSegmentStyle(seg)}<span style={getSegmentStyle(seg)}>{seg.text}</span>{:else}{seg.text}{/if}{/each}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if showCursor}<span class="cursor"></span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { TextSegment } from './utils';
|
import type { TextSegment } from './utils';
|
||||||
import type { Card } from '$lib/config';
|
import type { Card } from '$lib/config';
|
||||||
|
|
||||||
|
// Re-export TextSegment for convenience
|
||||||
|
export type { TextSegment };
|
||||||
|
|
||||||
export type LineType =
|
export type LineType =
|
||||||
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
| 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning'
|
||||||
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
| 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link'
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const lines: TerminalLine[] = [
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// TEXT FORMATTING
|
// TEXT FORMATTING
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
{ type: 'divider', content: 'TEXT FORMATTING' },
|
{ type: 'divider', content: 'TEXT FORMATTING', id: 'text-formatting' },
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
{ type: 'info', content: '(&blue,bold)Colors(&)' },
|
{ type: 'info', content: '(&blue,bold)Colors(&)' },
|
||||||
@@ -70,7 +70,7 @@ export const lines: TerminalLine[] = [
|
|||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// BUTTONS
|
// BUTTONS
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
{ type: 'divider', content: 'BUTTONS' },
|
{ type: 'divider', content: 'BUTTONS', id: 'buttons' },
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
{ type: 'info', content: '(&blue,bold)Button Styles(&)' },
|
{ type: 'info', content: '(&blue,bold)Button Styles(&)' },
|
||||||
@@ -182,6 +182,93 @@ export const lines: TerminalLine[] = [
|
|||||||
},
|
},
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
// GROUPS
|
||||||
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
{ type: 'divider', content: 'GROUPS' },
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
{ type: 'info', content: '(&blue,bold)Horizontal Group (Row)(&)' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'row',
|
||||||
|
groupAlign: 'center',
|
||||||
|
groupGap: '1rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'output', content: '(&primary,bold)Status:(&)', inline: true },
|
||||||
|
{ type: 'success', content: '(&success)Online(&)', inline: true },
|
||||||
|
{ type: 'button', content: 'Refresh', icon: 'mdi:refresh', style: 'accent', inline: true, action: () => console.log('Refresh clicked') }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
{ type: 'info', content: '(&blue,bold)Vertical Group (Column)(&)' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'column',
|
||||||
|
groupAlign: 'start',
|
||||||
|
groupGap: '0.25rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'header', content: '(&accent,bold)User Profile(&)' },
|
||||||
|
{ type: 'output', content: '(&muted)Name:(&) John Doe' },
|
||||||
|
{ type: 'output', content: '(&muted)Role:(&) Developer' },
|
||||||
|
{ type: 'output', content: '(&muted)Status:(&) (&success)Active(&)' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
{ type: 'info', content: '(&blue,bold)Group with Links(&)' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupAlign: 'start',
|
||||||
|
groupGap: '1rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'output', content: '(&primary,bold)Quick Links:(&)', inline: true },
|
||||||
|
{ type: 'link', href: '#text-formatting', content: '(&bg-blue,black)Formatting(&)', inline: true },
|
||||||
|
{ type: 'link', href: '#buttons', content: '(&bg-green,black)Buttons(&)', inline: true },
|
||||||
|
{ type: 'link', href: '#terminal-api', content: '(&bg-orange,black)API(&)', inline: true }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
|
{ type: 'info', content: '(&blue,bold)Nested Groups(&)' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'column',
|
||||||
|
groupGap: '0.5rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'header', content: '(&cyan,bold)Settings Panel(&)' },
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'row',
|
||||||
|
groupGap: '1rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'output', content: '(&muted)Theme:(&)', inline: true },
|
||||||
|
{ type: 'button', content: 'Dark', icon: 'mdi:weather-night', style: 'primary', inline: true, action: () => {} },
|
||||||
|
{ type: 'button', content: 'Light', icon: 'mdi:weather-sunny', style: 'accent', inline: true, action: () => {} }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'group',
|
||||||
|
content: '',
|
||||||
|
groupDirection: 'row',
|
||||||
|
groupGap: '1rem',
|
||||||
|
children: [
|
||||||
|
{ type: 'output', content: '(&muted)Speed:(&)', inline: true },
|
||||||
|
{ type: 'button', content: 'Slow', style: 'secondary', inline: true, action: () => {} },
|
||||||
|
{ type: 'button', content: 'Normal', style: 'primary', inline: true, action: () => {} },
|
||||||
|
{ type: 'button', content: 'Fast', style: 'accent', inline: true, action: () => {} }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
// IMAGES
|
// IMAGES
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
@@ -510,6 +597,9 @@ export const lines: TerminalLine[] = [
|
|||||||
{ type: 'output', content: "(&muted)// Checkbox(&)" },
|
{ type: 'output', content: "(&muted)// Checkbox(&)" },
|
||||||
{ type: 'output', content: "{ type: 'checkbox', content: 'Enable option', style: 'accent' }" },
|
{ type: 'output', content: "{ type: 'checkbox', content: 'Enable option', style: 'accent' }" },
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
{ type: 'output', content: "(&muted)// Group with children(&)" },
|
||||||
|
{ type: 'output', content: "{ type: 'group', groupDirection: 'row', groupGap: '1rem', children: [...] }" },
|
||||||
|
{ type: 'blank', content: '' },
|
||||||
{ type: 'output', content: "(&muted)// Select dropdown(&)" },
|
{ type: 'output', content: "(&muted)// Select dropdown(&)" },
|
||||||
{ type: 'output', content: "{ type: 'select', content: 'Choose:', selectOptions: [{ value: 'a', label: 'Option A' }] }" },
|
{ type: 'output', content: "{ type: 'select', content: 'Choose:', selectOptions: [{ value: 'a', label: 'Option A' }] }" },
|
||||||
{ type: 'blank', content: '' },
|
{ type: 'blank', content: '' },
|
||||||
|
|||||||
Reference in New Issue
Block a user