asciitorium
Version:
an ASCII CLUI framework
405 lines (404 loc) • 18 kB
JavaScript
import { Component } from '../core/Component.js';
import { isState } from '../core/environment.js';
import { resolveTextAlignment } from '../core/utils/textAlignmentUtils.js';
import { ScrollableViewport } from '../core/ScrollableViewport.js';
import { requestRender } from '../core/RenderScheduler.js';
export class Text extends Component {
constructor(options) {
// Also support JSX children syntax
let actualContent = options.content;
if (!actualContent && options.children) {
const children = Array.isArray(options.children)
? options.children
: [options.children];
// Process all children
if (children.length > 0) {
// If there's only one child, use it directly (preserves State objects)
if (children.length === 1) {
actualContent = children[0];
}
else {
// Multiple children: store the array for dynamic concatenation
actualContent = children;
}
}
}
// Default to empty string if no content provided
if (!actualContent) {
actualContent = '';
}
const { children, content, scrollable, wrap, textAlign, typewriter, typewriterSpeed, typewriterPauseFactor, ...componentProps } = options;
super({
...componentProps,
width: options.width, // Don't default to fill - let resolveSize handle it
height: options.height, // Let auto-sizing calculate height based on wrapped content
});
this.scrollableViewport = new ScrollableViewport();
this.totalLines = [];
this.scrollOffset = 0;
this.isScrollable = false;
this.shouldWrap = true;
this.stateUnsubscribers = [];
// Typewriter effect properties
this.isTypewriter = false;
this.typewriterSpeed = 20; // characters per second
this.typewriterPauseFactor = 10; // multiplier for pause after periods
this.visibleCharCount = 0;
this.fullContent = '';
this.isPaused = false;
this.focusable = false;
this.hasFocus = false;
this.source = actualContent;
this.textAlign = textAlign;
this.isScrollable = scrollable ?? false;
this.shouldWrap = wrap ?? true;
this.focusable = this.isScrollable;
// Initialize typewriter settings
this.isTypewriter = typewriter ?? false;
this.typewriterSpeed = typewriterSpeed ?? 20;
this.typewriterPauseFactor = typewriterPauseFactor ?? 10;
// Subscribe to any State objects in the source
this.subscribeToStates();
// Start typewriter effect if enabled
if (this.isTypewriter) {
this.startTypewriter();
}
}
startTypewriter() {
this.visibleCharCount = 0;
this.isPaused = false;
// Calculate interval in milliseconds from characters per second
const intervalMs = 1000 / this.typewriterSpeed;
this.typewriterIntervalId = globalThis.setInterval(() => {
// Skip if we're currently paused
if (this.isPaused) {
return;
}
// Get full content length (we'll cache it in getContentAsString)
const fullLength = this.fullContent.length;
if (this.visibleCharCount < fullLength) {
this.visibleCharCount++;
requestRender();
// Check if we just typed a period followed by a space or end of content
const currentChar = this.fullContent[this.visibleCharCount - 1];
const nextChar = this.visibleCharCount < fullLength ? this.fullContent[this.visibleCharCount] : null;
if (currentChar === '.' && (nextChar === ' ' || nextChar === null)) {
// Pause after periods for more natural reading pace
this.isPaused = true;
this.pauseTimeoutId = globalThis.setTimeout(() => {
this.isPaused = false;
this.pauseTimeoutId = undefined;
}, intervalMs * this.typewriterPauseFactor);
}
}
else {
// Typewriter complete, stop the interval
this.stopTypewriter();
}
}, intervalMs);
}
stopTypewriter() {
if (this.typewriterIntervalId !== undefined) {
globalThis.clearInterval(this.typewriterIntervalId);
this.typewriterIntervalId = undefined;
}
if (this.pauseTimeoutId !== undefined) {
globalThis.clearTimeout(this.pauseTimeoutId);
this.pauseTimeoutId = undefined;
}
this.isPaused = false;
}
subscribeToStates() {
const statesToSubscribe = [];
// Collect all State objects
if (Array.isArray(this.source)) {
for (const child of this.source) {
if (isState(child)) {
statesToSubscribe.push(child);
}
}
}
else if (isState(this.source)) {
statesToSubscribe.push(this.source);
}
// Subscribe to each State object
for (const state of statesToSubscribe) {
const listener = () => {
// If typewriter is enabled, restart it when content changes
if (this.isTypewriter) {
this.stopTypewriter();
this.startTypewriter();
}
requestRender();
};
state.subscribe(listener);
this.stateUnsubscribers.push(() => state.unsubscribe(listener));
}
}
destroy() {
// Stop typewriter effect if active
this.stopTypewriter();
// Unsubscribe from all State objects
for (const unsubscribe of this.stateUnsubscribers) {
unsubscribe();
}
this.stateUnsubscribers = [];
super.destroy();
}
handleEvent(event) {
if (!this.isScrollable || this.totalLines.length === 0) {
return false;
}
const innerHeight = this.height - (this.border ? 2 : 0);
const maxVisible = Math.max(1, innerHeight);
// Only handle scroll events if content exceeds viewport
if (this.totalLines.length <= maxVisible) {
return false;
}
const prevScrollOffset = this.scrollOffset;
const newScrollOffset = this.scrollableViewport.handleScrollEvent(event, this.scrollOffset, this.totalLines.length);
this.scrollOffset = newScrollOffset;
return this.scrollOffset !== prevScrollOffset;
}
// Override resolveSize to handle width and height auto-sizing
resolveSize(context) {
// First, let the base class resolve any explicitly set dimensions
super.resolveSize(context);
// Handle width auto-sizing if not explicitly set
if (this.getOriginalWidth() === undefined) {
const contentLength = this.getContentAsString().length;
const borderAdjustment = this.border ? 2 : 0;
// Account for this component's own left/right gaps when calculating max width
let gapLeft = 0;
let gapRight = 0;
if (this.gap) {
if (Array.isArray(this.gap)) {
// Array format: [top, right, bottom, left] or [vertical, horizontal]
if (this.gap.length === 4) {
gapLeft = this.gap[3];
gapRight = this.gap[1];
}
else if (this.gap.length === 2) {
gapLeft = gapRight = this.gap[1];
}
}
else if (typeof this.gap === 'object') {
// Object format: { left, right, ... }
gapLeft = this.gap.left || 0;
gapRight = this.gap.right || 0;
}
else if (typeof this.gap === 'number') {
// Single number: applies to all sides
gapLeft = gapRight = this.gap;
}
}
const gapAdjustment = gapLeft + gapRight;
// Size based on content, but respect parent's available width minus our gaps
const contentBasedWidth = contentLength + borderAdjustment;
const maxWidth = Math.max(1, context.availableWidth - gapAdjustment);
// Use the smaller of content width or available width
this.width = Math.max(1, Math.min(contentBasedWidth, maxWidth));
}
// Then calculate height based on the resolved width if height is not explicitly set
if (this.getOriginalHeight() === undefined) {
const innerWidth = this.width - (this.border ? 2 : 0);
if (innerWidth > 0) {
const wrappedLines = this.wrapText(this.getContentAsString(), innerWidth, Infinity);
// Store total lines for scrollable text
if (this.isScrollable) {
this.totalLines = wrappedLines;
}
const borderAdjustment = this.border ? 2 : 0;
// For scrollable text, don't auto-expand height - keep it constrained if set
if (this.isScrollable && this.getOriginalHeight() !== undefined) {
// Height was explicitly set, use it
}
else {
// Non-scrollable or no height set, use content-based sizing
const newHeight = Math.max(1, wrappedLines.length + borderAdjustment);
this.height = newHeight;
}
}
}
else if (this.isScrollable) {
// Height is explicitly set and scrollable - store total lines
const innerWidth = this.width - (this.border ? 2 : 0);
if (innerWidth > 0) {
this.totalLines = this.wrapText(this.getContentAsString(), innerWidth, Infinity);
}
}
}
getContentAsString() {
let result;
if (Array.isArray(this.source)) {
// Multiple children: concatenate them dynamically
result = this.source.map(child => {
if (typeof child === 'string') {
return child;
}
else if (isState(child)) {
const value = child.value;
return value == null ? '' : String(value);
}
else {
return String(child);
}
}).join('');
}
else if (isState(this.source)) {
const value = this.source.value;
result = value == null ? '' : String(value);
}
else {
result = this.source == null ? '' : String(this.source);
}
// Convert ¶ (pilcrow) to newline for text wrapping, but allow escaping with backslash
result = result.replace(/\\¶/g, '\u0000').replace(/¶/g, '\n').replace(/\u0000/g, '¶');
// Cache the full content for typewriter effect
this.fullContent = result;
// If typewriter is enabled and not complete, return truncated content
if (this.isTypewriter && this.visibleCharCount < result.length) {
return result.substring(0, this.visibleCharCount);
}
return result;
}
draw() {
if (!this.visible) {
// If not visible, return empty buffer
return [];
}
super.draw(); // fills buffer, draws borders, etc.
const innerWidth = this.width - (this.border ? 2 : 0);
const innerHeight = this.height - (this.border ? 2 : 0);
let linesToDraw;
if (this.isScrollable && this.totalLines.length > 0) {
// For scrollable text, use stored total lines and apply scrolling
const maxVisible = Math.max(1, innerHeight);
if (this.totalLines.length > maxVisible) {
// Use ScrollableViewport to calculate what to show
const scrollWindow = this.scrollableViewport.calculateScrollWindow({
items: this.totalLines,
totalCount: this.totalLines.length,
maxVisible,
focusedIndex: this.scrollOffset
});
linesToDraw = scrollWindow.visibleItems;
// Draw scroll indicators
const borderPad = this.border ? 1 : 0;
this.scrollableViewport.drawScrollIndicators(this.buffer, this.width, this.height, borderPad, scrollWindow.showUpArrow, scrollWindow.showDownArrow);
}
else {
// Content fits in viewport, show all
linesToDraw = this.totalLines;
}
}
else {
// Non-scrollable text, use traditional wrapping with height constraint
linesToDraw = this.wrapText(this.getContentAsString(), innerWidth, innerHeight);
}
// Calculate the maximum line width for alignment purposes
const maxLineWidth = Math.max(...linesToDraw.map(line => line.length), 0);
// Use textAlign for positioning text within the component
const { x, y } = resolveTextAlignment(this.textAlign, innerWidth, innerHeight, maxLineWidth, linesToDraw.length);
const drawX = this.border ? x + 1 : x;
const drawY = this.border ? y + 1 : y;
// Draw each line
for (let lineIndex = 0; lineIndex < linesToDraw.length && lineIndex < innerHeight; lineIndex++) {
const line = linesToDraw[lineIndex];
const currentY = drawY + lineIndex;
if (currentY >= this.height)
break;
// Calculate per-line horizontal alignment offset
const lineWidth = line.length;
let lineX = drawX;
// Apply horizontal alignment for each line
if (this.textAlign) {
if (this.textAlign.includes('right')) {
lineX = drawX + (maxLineWidth - lineWidth);
}
else if (this.textAlign === 'center' || this.textAlign === 'top' || this.textAlign === 'bottom') {
lineX = drawX + Math.floor((maxLineWidth - lineWidth) / 2);
}
}
for (let charIndex = 0; charIndex < line.length && charIndex < innerWidth; charIndex++) {
const currentX = lineX + charIndex;
if (currentX < this.width) {
this.buffer[currentY][currentX] = line[charIndex];
}
}
}
// Draw focus indicator for scrollable text with border
if (this.hasFocus && this.border && this.isScrollable) {
// Draw ' > ' at positions (1, 0), (2, 0), (3, 0)
if (this.buffer.length > 0 && this.buffer[0].length > 3) {
this.buffer[0][2] = ' ';
this.buffer[0][3] = '>';
this.buffer[0][4] = ' ';
}
}
return this.buffer;
}
wrapText(text, maxWidth, maxHeight) {
if (maxWidth <= 0)
return [];
// If wrapping is disabled, just split by newlines and truncate
if (!this.shouldWrap) {
const lines = text.split('\n');
return lines.slice(0, maxHeight).map(line => line.length > maxWidth ? line.substring(0, maxWidth) : line);
}
// First split by newlines to handle explicit line breaks
const paragraphs = text.split('\n');
const lines = [];
for (const paragraph of paragraphs) {
if (lines.length >= maxHeight)
break;
if (paragraph.trim() === '') {
// Empty line
lines.push('');
continue;
}
const words = paragraph.split(' ');
let currentLine = '';
for (const word of words) {
const testLine = currentLine ? `${currentLine} ${word}` : word;
if (testLine.length <= maxWidth) {
currentLine = testLine;
}
else {
// If current line has content, push it and start new line
if (currentLine) {
lines.push(currentLine);
if (lines.length >= maxHeight)
break;
currentLine = word;
}
else {
// Word is longer than maxWidth, break it
if (word.length > maxWidth) {
let remainingWord = word;
while (remainingWord.length > maxWidth &&
lines.length < maxHeight) {
lines.push(remainingWord.substring(0, maxWidth));
remainingWord = remainingWord.substring(maxWidth);
}
if (remainingWord.length > 0 && lines.length < maxHeight) {
currentLine = remainingWord;
}
}
else {
currentLine = word;
}
}
}
// Stop if we've reached max height
if (lines.length >= maxHeight)
break;
}
// Add remaining content if there's space
if (currentLine && lines.length < maxHeight) {
lines.push(currentLine);
}
}
return lines.slice(0, maxHeight);
}
}