239 lines
6.4 KiB
TypeScript
239 lines
6.4 KiB
TypeScript
// 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;
|
||
}
|