diff --git a/packages/core/src/component.ts b/packages/core/src/component.ts index 845d32a..16a20c8 100644 --- a/packages/core/src/component.ts +++ b/packages/core/src/component.ts @@ -3,114 +3,154 @@ import { wrapAnsi } from 'fast-wrap-ansi'; import { cursor, erase } from 'sisteransi'; import { getColumns } from './utils/index.js'; -export abstract class Component { - #lastFrame?: string; - #output: Writable; - #active = false; - - constructor(output: Writable = process.stdout) { - this.#output = output; +function renderAsString(template: Template, host?: Component): string { + if (typeof template === 'string') { + return template; } - public abstract render(): Template; + let result = ''; - public onMount(): void { - return; + for (const part of template) { + if (typeof part === 'string') { + result += part; + } else if (part instanceof Component) { + if (host) { + host.children.add(part); + } + result += renderAsString(part.render(), part); + } } - public onUnmount(): void { - return; - } + return result; +} - #renderDiff(from: string | undefined, to: string): void { - const output = this.#output; - const fromLines = from ? from.split('\n') : []; - const toLines = to.split('\n'); - const fromLineCount = fromLines.length; - const toLineCount = toLines.length; - const maxLines = Math.max(fromLineCount, toLineCount); +function renderDiff(from: string | undefined, to: string, output: Writable): void { + const fromLines = from ? from.split('\n') : []; + const toLines = to.split('\n'); + const fromLineCount = fromLines.length; + const toLineCount = toLines.length; + const maxLines = Math.max(fromLineCount, toLineCount); - output.write(cursor.hide); - output.write(cursor.left); + output.write(cursor.hide); + output.write(cursor.left); - if (fromLineCount > 0) { - output.write(cursor.up(fromLineCount)); - } + if (fromLineCount > 0) { + output.write(cursor.up(fromLineCount)); + } - for (let i = 0; i < maxLines; i++) { - const fromLine = fromLines[i]; - const toLine = toLines[i]; + for (let i = 0; i < maxLines; i++) { + const fromLine = fromLines[i]; + const toLine = toLines[i]; - if (fromLine === toLine) { - // do nothing - } else if (toLine === undefined) { - output.write(erase.line); - } else { + if (fromLine === toLine) { + output.write(cursor.down(1)); + } else { + output.write(erase.line); + if (toLine !== undefined) { output.write(`${toLine}\n`); - } - - if (i < fromLineCount - 1) { + } else { output.write(cursor.down(1)); } } + } - output.write(cursor.show); + output.write(cursor.show); +} + +function render( + template: Template, + output: Writable, + lastFrame: string | undefined, + host?: Component +): string { + const frame = renderAsString(template, host); + const wrapped = wrapAnsi(frame, getColumns(output), { + hard: true, + trim: false, + }); + + if (wrapped === lastFrame) { + return lastFrame; } - #render(): string { - const template = this.render(); + renderDiff(lastFrame, wrapped, output); - if (typeof template === 'string') { - return template; - } + return wrapped; +} - let result = ''; +export abstract class Component { + public children: Set = new Set(); + public host?: Component; - for (const part of template) { - if (typeof part === 'string') { - result += part; - } else if (part instanceof Component) { - this.#children.add(part); - result += part.#render(); - } - } + constructor(host: Component | undefined) { + this.host = host; + } - return result; + public abstract render(): Template; + + public onMount(): void { + for (const child of this.children) { + child.onMount(); + } } - #children: Set = new Set(); + public onUnmount(): void { + for (const child of this.children) { + child.onUnmount(); + } + } public requestUpdate(): void { - if (!this.#active) { - this.#active = true; - this.onMount(); + if (this.host) { + this.host.requestUpdate(); } + } +} - const frame = this.#render(); - const wrapped = wrapAnsi(frame, getColumns(this.#output), { - hard: true, - trim: false, - }); +export class Renderer extends Component { + #output: Writable; + #lastFrame: string | undefined; + #root?: Component; + + constructor(output: Writable = process.stdout) { + super(undefined); + this.#output = output; + } - if (wrapped === this.#lastFrame) { - return; + render(): Template { + const result: TemplateArray = []; + for (const child of this.children) { + const childResult = child.render(); + if (typeof childResult === 'string') { + result.push(childResult); + } else { + for (const part of childResult) { + result.push(part); + } + } } + return result; + } - this.#renderDiff(this.#lastFrame, wrapped); + mount(comp: Component): void { + this.#root = comp; + this.children.add(comp); + comp.host = this; + this.requestUpdate(); + this.onMount(); + } - this.#lastFrame = wrapped; + requestUpdate() { + this.#lastFrame = render(this.render(), this.#output, this.#lastFrame, this.#root); } - public unmount(): void { - if (!this.#active) { - return; - } - this.#active = false; + unmount() { + this.#lastFrame = undefined; this.onUnmount(); } } -export const term = (strings: TemplateStringsArray, ...values: unknown[]): TemplateArray => { +export const term = (strings: TemplateStringsArray, ...values: unknown[]): Template => { const result: TemplateArray = []; for (let i = 0; i < strings.length; i++) { @@ -134,23 +174,51 @@ type Template = string | TemplateArray; export class SmileComponent extends Component { #position: number = 0; + #direction: -1 | 1 = 1; + #timer?: ReturnType; - render(): Template { - if (this.#position === 10) { - this.#position--; - } else { + override onMount() { + super.onMount(); + this.#timer = setInterval(() => { + this.#updatePosition(); + this.requestUpdate(); + }, 100); + } + + override onUnmount() { + super.onUnmount(); + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = undefined; + } + } + + #updatePosition() { + if (this.#direction === 1) { this.#position++; + if (this.#position === 10) { + this.#direction = -1; + } + } else { + this.#position--; + if (this.#position === 0) { + this.#direction = 1; + } } - return term`${' '.repeat(this.#position)}(͡° ͜ʖ ͡°)`; + } + + render(): Template { + return term`${' '.repeat(this.#position)}💣 𝐂𝐋𝐀𝐂𝐊`; } } export class SecondsComponent extends Component { #seconds = 0; #timer?: ReturnType; - #smileComponent = new SmileComponent(); + #smileComponent = new SmileComponent(this); override onMount() { + super.onMount(); this.#timer = setInterval(() => { this.#seconds++; this.requestUpdate(); @@ -158,6 +226,7 @@ export class SecondsComponent extends Component { } override onUnmount() { + super.onUnmount(); if (this.#timer) { clearInterval(this.#timer); this.#timer = undefined; @@ -168,7 +237,7 @@ export class SecondsComponent extends Component { return term`| Seconds elapsed: | ${this.#seconds} | Press Ctrl+C to exit -| <-- ${this.#smileComponent} --> +| ${this.#smileComponent} `; } }