diff --git a/README.md b/README.md index 3c80d66..4b58b2d 100644 --- a/README.md +++ b/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 ### TerminalTUI @@ -411,15 +433,24 @@ src/lib/components/ ├── terminal-typing.ts # Typing animation engine ├── terminal-keyboard.ts# Keyboard navigation handler ├── TuiHeader.svelte # Top status bar - ├── TuiBody.svelte # Scrollable content area + ├── TuiBody.svelte # Scrollable content area (uses TuiLine) ├── TuiFooter.svelte # Bottom status bar + ├── TuiLine.svelte # Unified line renderer for all types + ├── TuiGroup.svelte # Container for grouped elements ├── TuiButton.svelte # Full-width button ├── TuiLink.svelte # Inline clickable link ├── TuiCard.svelte # Card with header/body/footer + ├── TuiCardGrid.svelte # Grid layout for cards ├── TuiProgress.svelte # Animated progress bar ├── TuiAccordion.svelte # Collapsible sections ├── 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 diff --git a/package.json b/package.json index 3f9b4a5..28db855 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite dev --host", "build": "vite build", - "start": "vite build && node server/server.js" + "server": "bun server/server.js", + "start": "vite build && bun server/server.js" }, "devDependencies": { "@sveltejs/adapter-node": "^5.4.0", diff --git a/src/lib/components/tui/TuiBody.svelte b/src/lib/components/tui/TuiBody.svelte index acb20f7..fbc631a 100644 --- a/src/lib/components/tui/TuiBody.svelte +++ b/src/lib/components/tui/TuiBody.svelte @@ -1,22 +1,7 @@
- {#each groupedLines as group} - {#if group.type === 'inline-group'} - + {#each processedGroups as group, gi (gi)} + {#if group.kind === 'inline'}
- {#each group.items as displayed, j} - {@const line = displayed.parsed.line} - {@const idx = group.indices[j]} - {@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)} - {@const showImage = displayed.showImage} - {#if line.type === 'button'} - - {:else if line.type === 'link'} - onLinkClick(idx)} /> - {:else if line.type === 'tooltip'} - - {:else if line.type === 'progress'} - - {:else if line.type === 'input'} - - {:else if line.type === 'checkbox'} - - {:else if line.type === 'toggle'} - - {:else if line.type === 'group'} - - {:else if line.type === 'image' && showImage} -
- {line.imageAlt - {#if line.content} - {line.content} - {/if} -
- {:else if line.type !== 'image'} - - {getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each} - - {/if} + {#each group.items as item (item.index)} + {/each}
- {:else if group.type === 'single'} - {@const i = group.index} - {@const displayed = group.displayed} - {@const line = displayed.parsed.line} - {@const visibleSegments = getSegmentsUpToChar(displayed.parsed.segments, displayed.charIndex)} - {@const complete = displayed.complete} - {@const showImage = displayed.showImage} - {#if line.type === 'divider'} -
- - {#if line.content} - {line.content} - {/if} - -
- {:else if line.type === 'button'} - - {:else if line.type === 'link'} - - {:else if line.type === 'card'} - - {:else if line.type === 'cardgrid'} - - {:else if line.type === 'progress'} - - {:else if line.type === 'accordion'} - - {:else if line.type === 'table'} - - {:else if line.type === 'tooltip'} -
- -
- {:else if line.type === 'input'} - - {:else if line.type === 'textarea'} - - {:else if line.type === 'checkbox'} - - {:else if line.type === 'radio'} - - {:else if line.type === 'select'} - - {:else if line.type === 'toggle'} - - {:else if line.type === 'group'} - - {:else} -
- {#if line.type === 'command' || line.type === 'prompt'} - - {user.username}@{user.hostname} - :~$ - - {/if} - - {#if line.type === 'image' && showImage} -
- {line.imageAlt - {#if line.content} - {line.content} - {/if} -
- {:else if line.type === 'header'} - - - {#each visibleSegments as segment} - {#if segment.icon} - - {:else if getSegmentStyle(segment)} - {segment.text} - {:else} - {segment.text} - {/if} - {/each} - - {:else if line.type !== 'blank'} - - {getLinePrefix(line.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each} - - {/if} - - {#if terminalSettings.showCursor && i === currentLineIndex && !complete && isTyping && line.type !== 'image' && line.type !== 'blank'} - - {/if} -
- {/if} + {:else} + {/if} {/each} diff --git a/src/lib/components/tui/TuiGroup.svelte b/src/lib/components/tui/TuiGroup.svelte index 52cf446..2c18844 100644 --- a/src/lib/components/tui/TuiGroup.svelte +++ b/src/lib/components/tui/TuiGroup.svelte @@ -1,17 +1,7 @@ -
- {#each parsedChildren as parsed, idx} - {@const child = parsed.line} - {@const visibleSegments = parsed.segments} - {@const childInline = child.inline !== false} - - {#if child.type === 'image'} -
- {child.imageAlt - {#if child.content} - {child.content} - {/if} -
- {:else if child.type === 'button'} - handleNavigation(child)} onHover={() => {}} inline={childInline} /> - {:else if child.type === 'link'} - onLinkClick(idx)} /> - {:else if child.type === 'tooltip'} - - {:else if child.type === 'progress'} - - {:else if child.type === 'input'} - - {:else if child.type === 'checkbox'} - - {:else if child.type === 'toggle'} - - {:else if child.type === 'group'} - - {:else if child.type === 'header'} - - - {#each visibleSegments as segment} - {#if segment.icon} - - {:else if getSegmentStyle(segment)} - {segment.text} - {:else} - {segment.text} - {/if} - {/each} - - {:else if child.type === 'blank'} - - {:else if child.type === 'command' || child.type === 'prompt'} - - - {user.username}@{user.hostname} - :~$ - - - {#each visibleSegments as segment} - {#if segment.icon} - - {:else if getSegmentStyle(segment)} - {segment.text} - {:else} - {segment.text} - {/if} - {/each} - - - {:else} - - {getLinePrefix(child.type)}{#each visibleSegments as segment}{#if segment.icon}{:else if getSegmentStyle(segment)}{segment.text}{:else}{segment.text}{/if}{/each} - - {/if} +
+ {#each parsedChildren as parsed, idx (idx)} + {/each}
@@ -154,117 +74,15 @@ 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 { - from { - opacity: 0; - transform: translateX(-5px); - } - to { - opacity: 1; - transform: translateX(0); - } + from { opacity: 0; transform: translateX(-5px); } + to { opacity: 1; transform: translateX(0); } } - /* Mobile: inline groups become vertical stacked */ @media (max-width: 768px) { .tui-group.inline { flex-direction: column; align-items: stretch; } - - .tui-group.inline .tui-image img { - max-width: 100% !important; - width: 100%; - } } diff --git a/src/lib/components/tui/TuiLine.svelte b/src/lib/components/tui/TuiLine.svelte new file mode 100644 index 0000000..746acb4 --- /dev/null +++ b/src/lib/components/tui/TuiLine.svelte @@ -0,0 +1,138 @@ + + +{#if isBlank} + {#if inline}{:else}
{/if} +{:else if isDivider} +
+ + {#if line.content}{line.content}{/if} + +
+{:else if line.type === 'button'} + +{:else if isLink} + {#if inline} + handleNavigation(line)} /> + {:else} + + {/if} +{:else if isTooltip} + {#if inline}{:else}
{/if} +{:else if line.type === 'card'} + +{:else if line.type === 'cardgrid'} + +{:else if line.type === 'progress'} + +{:else if line.type === 'accordion'} + +{:else if line.type === 'table'} + +{:else if line.type === 'input'} + +{:else if line.type === 'textarea'} + +{:else if line.type === 'checkbox'} + +{:else if line.type === 'radio'} + +{:else if line.type === 'select'} + +{:else if line.type === 'toggle'} + +{:else if line.type === 'group'} + +{:else if isImage && showImage} +
+ {line.imageAlt + {#if line.content}{line.content}{/if} +
+{:else if !isImage} + {#if inline} + + {getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}{:else if getSegmentStyle(seg)}{seg.text}{:else}{seg.text}{/if}{/each} + + {:else} +
+ {#if isPrompt} + + {user.username}@{user.hostname} + :~$ + + {/if} + {#if isHeader} + + + {#each segments as seg}{#if seg.icon}{:else if getSegmentStyle(seg)}{seg.text}{:else}{seg.text}{/if}{/each} + + {:else} + + {getLinePrefix(line.type)}{#each segments as seg}{#if seg.icon}{:else if getSegmentStyle(seg)}{seg.text}{:else}{seg.text}{/if}{/each} + + {/if} + {#if showCursor}{/if} +
+ {/if} +{/if} diff --git a/src/lib/components/tui/types.ts b/src/lib/components/tui/types.ts index a9a854e..2cf0f0c 100644 --- a/src/lib/components/tui/types.ts +++ b/src/lib/components/tui/types.ts @@ -1,6 +1,9 @@ import type { TextSegment } from './utils'; import type { Card } from '$lib/config'; +// Re-export TextSegment for convenience +export type { TextSegment }; + export type LineType = | 'command' | 'output' | 'prompt' | 'error' | 'success' | 'info' | 'warning' | 'image' | 'blank' | 'header' | 'button' | 'divider' | 'link' diff --git a/src/lib/pages/components.ts b/src/lib/pages/components.ts index 343693d..85f4d7a 100644 --- a/src/lib/pages/components.ts +++ b/src/lib/pages/components.ts @@ -26,7 +26,7 @@ export const lines: TerminalLine[] = [ // ═══════════════════════════════════════════════════════════════ // TEXT FORMATTING // ═══════════════════════════════════════════════════════════════ - { type: 'divider', content: 'TEXT FORMATTING' }, + { type: 'divider', content: 'TEXT FORMATTING', id: 'text-formatting' }, { type: 'blank', content: '' }, { type: 'info', content: '(&blue,bold)Colors(&)' }, @@ -70,7 +70,7 @@ export const lines: TerminalLine[] = [ // ═══════════════════════════════════════════════════════════════ // BUTTONS // ═══════════════════════════════════════════════════════════════ - { type: 'divider', content: 'BUTTONS' }, + { type: 'divider', content: 'BUTTONS', id: 'buttons' }, { type: 'blank', content: '' }, { type: 'info', content: '(&blue,bold)Button Styles(&)' }, @@ -182,6 +182,93 @@ export const lines: TerminalLine[] = [ }, { 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 // ═══════════════════════════════════════════════════════════════ @@ -510,6 +597,9 @@ export const lines: TerminalLine[] = [ { type: 'output', content: "(&muted)// Checkbox(&)" }, { type: 'output', content: "{ type: 'checkbox', content: 'Enable option', style: 'accent' }" }, { 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: "{ type: 'select', content: 'Choose:', selectOptions: [{ value: 'a', label: 'Option A' }] }" }, { type: 'blank', content: '' },