Files
Website/src/lib/components/tui/utils.ts
2025-12-01 17:23:22 +00:00

239 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared utilities used by TUI components
export interface TextSegment {
text: string;
color?: string;
background?: string;
bold?: boolean;
dim?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
overline?: boolean;
// For inline icons
icon?: string;
iconSize?: number;
// For inline clickable text
href?: string;
action?: () => void;
}
// Color maps for each theme
export type ThemeColorMap = Record<string, string>;
// Default color map (fallback) - hex values as defaults, overwritten by theme colorMap at runtime
export const defaultColorMap: ThemeColorMap = {
// Basic colors
'red': '#f38ba8',
'green': '#a6e3a1',
'yellow': '#f9e2af',
'blue': '#89b4fa',
'magenta': '#cba6f7',
'cyan': '#94e2d5',
'white': '#cdd6f4',
'gray': '#6c7086',
'orange': '#fab387',
'pink': '#f5c2e7',
'black': '#1e1e2e',
'surface': '#313244',
// Extended colors
'teal': '#94e2d5',
'sky': '#89dceb',
'sapphire': '#74c7ec',
'lavender': '#b4befe',
'peach': '#fab387',
'maroon': '#eba0ac',
'mauve': '#cba6f7',
'flamingo': '#f2cdcd',
'rosewater': '#f5e0dc',
// Semantic colors
'primary': '#cba6f7',
'accent': '#a6e3a1',
'muted': '#6c7086',
'error': '#f38ba8',
'success': '#a6e3a1',
'warning': '#f9e2af',
'info': '#89b4fa',
// Theme colors
'text': '#cdd6f4',
'textMuted': '#a6adc8',
'background': '#1e1e2e',
'backgroundLight': '#313244',
'border': '#45475a',
'terminal': '#1e1e2e',
'terminalPrompt': '#cba6f7',
'terminalUser': '#a6e3a1',
'terminalPath': '#89b4fa',
};
// Legacy alias for backwards compatibility
export const colorMap = defaultColorMap;
// Text style keywords
const textStyles = ['bold', 'dim', 'italic', 'underline', 'strikethrough', 'overline'];
export function parseColorText(text: string, colors: ThemeColorMap = colorMap): TextSegment[] {
const segments: TextSegment[] = [];
// Match (&icon, iconName) patterns FIRST, then (&specs)content(&)
// This prevents (&icon, ...) from being consumed as the start of a color block
const regex = /\(&icon,\s*([^)]+)\)|\(&([^)]+)\)(.*?)\(&\)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = regex.exec(text)) !== null) {
if (match.index > lastIndex) {
segments.push({ text: text.slice(lastIndex, match.index) });
}
// Check if this is an icon match (Group 1 is the icon name)
if (match[1]) {
const iconName = match[1].trim();
segments.push({ text: '', icon: iconName });
lastIndex = match.index + match[0].length;
continue;
}
// Standard text formatting (Group 2 is specs, Group 3 is content)
const specs = match[2].split(',').map(s => s.trim().toLowerCase());
const content = match[3];
const segment: TextSegment = { text: content };
for (const spec of specs) {
// Text styles
if (spec === 'bold') segment.bold = true;
else if (spec === 'dim') segment.dim = true;
else if (spec === 'italic') segment.italic = true;
else if (spec === 'underline') segment.underline = true;
else if (spec === 'strikethrough' || spec === 'strike') segment.strikethrough = true;
else if (spec === 'overline') segment.overline = true;
// Background color (bg-colorname or bg-#hex)
else if (spec.startsWith('bg-')) {
const bgColor = spec.slice(3);
if (colors[bgColor]) {
segment.background = colors[bgColor];
} else if (bgColor.startsWith('#')) {
segment.background = bgColor;
}
}
// Foreground color
else if (colors[spec] && !textStyles.includes(spec)) {
segment.color = colors[spec];
} else if (spec.startsWith('#')) {
segment.color = spec;
}
}
segments.push(segment);
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
segments.push({ text: text.slice(lastIndex) });
}
if (segments.length === 0) {
segments.push({ text });
}
return segments;
}
// Get plain text from segments (for length calculation)
export function getPlainText(segments: TextSegment[]): string {
return segments.map(s => s.text).join('');
}
// Get segments up to a certain character count (for typing animation)
export function getSegmentsUpToChar(segments: TextSegment[], charCount: number): TextSegment[] {
const result: TextSegment[] = [];
let remaining = charCount;
for (const segment of segments) {
if (remaining <= 0) break;
if (segment.text.length <= remaining) {
result.push(segment);
remaining -= segment.text.length;
} else {
// Partial segment
result.push({ ...segment, text: segment.text.slice(0, remaining) });
remaining = 0;
}
}
return result;
}
export function getSegmentStyle(segment: TextSegment): string {
const styles: string[] = [];
if (segment.color) styles.push(`color: ${segment.color}`);
if (segment.background) styles.push(`background-color: ${segment.background}; padding: 0.1em 0.25em; border-radius: 3px`);
if (segment.bold) styles.push('font-weight: bold');
if (segment.dim) styles.push('opacity: 0.6');
if (segment.italic) styles.push('font-style: italic');
// Combine text decorations
const decorations: string[] = [];
if (segment.underline) decorations.push('underline');
if (segment.strikethrough) decorations.push('line-through');
if (segment.overline) decorations.push('overline');
if (decorations.length > 0) {
styles.push(`text-decoration: ${decorations.join(' ')}`);
}
return styles.join('; ');
}
export function getLinePrefix(type: string): string {
switch (type) {
case 'error':
return '✗ ';
case 'success':
return '✓ ';
case 'warning':
return '⚠ ';
case 'info':
return ' ';
default:
return '';
}
}
export function getButtonStyle(style?: string): string {
switch (style) {
case 'primary':
return 'var(--terminal-primary)';
case 'accent':
return 'var(--terminal-accent)';
case 'warning':
return '#f9e2af';
case 'error':
return '#f38ba8';
default:
return 'var(--terminal-text)';
}
}
export function parseDimension(value: string | number | undefined): string | undefined {
if (value === undefined) return undefined;
if (typeof value === 'number') {
// If it's a decimal between 0 and 1, treat as percentage
if (value > 0 && value <= 1) {
return `${value * 100}%`;
}
return `${value}px`;
}
if (typeof value === 'string') {
// Handle fractions like "1/2", "1/3"
if (value.includes('/')) {
const [num, den] = value.split('/').map(Number);
if (!isNaN(num) && !isNaN(den) && den !== 0) {
return `${(num / den) * 100}%`;
}
}
// Return as is if it already has a unit or is just a number string
return value;
}
return undefined;
}