From 155e1a042b12e82929bdb26571385b5dea0f0b39 Mon Sep 17 00:00:00 2001 From: Vadim Demedes Date: Thu, 18 Jun 2020 13:57:43 +0300 Subject: [PATCH] Rerender on resize (#304) --- src/ink.tsx | 27 ++++++++---- src/renderer.ts | 73 +++++++++++++++----------------- test/components.tsx | 36 ++++------------ test/errors.tsx | 25 +++++------ test/focus.tsx | 6 +-- test/helpers/create-stdout.ts | 19 +++++++++ test/helpers/render-to-string.ts | 29 ++----------- test/reconciler.tsx | 7 +-- test/render.tsx | 42 ++++++++++++++++-- 9 files changed, 133 insertions(+), 131 deletions(-) create mode 100644 test/helpers/create-stdout.ts diff --git a/src/ink.tsx b/src/ink.tsx index 2412b5ae4..d49a9765b 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -7,8 +7,7 @@ import ansiEscapes from 'ansi-escapes'; import originalIsCI from 'is-ci'; import autoBind from 'auto-bind'; import reconciler from './reconciler'; -import createRenderer from './renderer'; -import type {Renderer} from './renderer'; +import render from './renderer'; import signalExit from 'signal-exit'; import patchConsole from 'patch-console'; import * as dom from './dom'; @@ -41,9 +40,9 @@ export default class Ink { // This variable is used only in debug mode to store full static output // so that it's rerendered every time, not just new static parts, like in non-debug mode private fullStaticOutput: string; - private readonly renderer: Renderer; private exitPromise?: Promise; private restoreConsole?: () => void; + private readonly unsubscribeResize?: () => void; constructor(options: Options) { autoBind(this); @@ -59,11 +58,6 @@ export default class Ink { }); this.rootNode.onImmediateRender = this.onRender; - - this.renderer = createRenderer({ - terminalWidth: options.stdout.columns - }); - this.log = logUpdate.create(options.stdout); this.throttledLog = options.debug ? this.log @@ -100,6 +94,14 @@ export default class Ink { if (options.patchConsole) { this.patchConsole(); } + + if (!isCI) { + options.stdout.on('resize', this.onRender); + + this.unsubscribeResize = () => { + options.stdout.off('resize', this.onRender); + }; + } } resolveExitPromise: () => void = () => {}; @@ -111,7 +113,10 @@ export default class Ink { return; } - const {output, outputHeight, staticOutput} = this.renderer(this.rootNode); + const {output, outputHeight, staticOutput} = render( + this.rootNode, + this.options.stdout.columns + ); // If output isn't empty, it means new children have been added to it const hasStaticOutput = staticOutput && staticOutput !== '\n'; @@ -231,6 +236,10 @@ export default class Ink { this.restoreConsole(); } + if (typeof this.unsubscribeResize === 'function') { + this.unsubscribeResize(); + } + // CIs don't handle erasing ansi escapes well, so it's better to // only render last frame of non-static output if (isCI) { diff --git a/src/renderer.ts b/src/renderer.ts index b90dc280d..3517eb209 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,61 +1,54 @@ import Yoga from 'yoga-layout-prebuilt'; import renderNodeToOutput from './render-node-to-output'; import Output from './output'; -import {setStyle} from './dom'; import type {DOMElement} from './dom'; -export type Renderer = ( - node: DOMElement -) => { +interface Result { output: string; outputHeight: number; staticOutput: string; -}; - -export default ({terminalWidth = 100}: {terminalWidth: number}): Renderer => { - return (node: DOMElement) => { - setStyle(node, { - width: terminalWidth - }); +} - if (node.yogaNode) { - node.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); - - const output = new Output({ - width: node.yogaNode.getComputedWidth(), - height: node.yogaNode.getComputedHeight() - }); +export default (node: DOMElement, terminalWidth: number): Result => { + node.yogaNode!.setWidth(terminalWidth); - renderNodeToOutput(node, output, {skipStaticElements: true}); + if (node.yogaNode) { + node.yogaNode.calculateLayout(undefined, undefined, Yoga.DIRECTION_LTR); - let staticOutput; + const output = new Output({ + width: node.yogaNode.getComputedWidth(), + height: node.yogaNode.getComputedHeight() + }); - if (node.staticNode?.yogaNode) { - staticOutput = new Output({ - width: node.staticNode.yogaNode.getComputedWidth(), - height: node.staticNode.yogaNode.getComputedHeight() - }); + renderNodeToOutput(node, output, {skipStaticElements: true}); - renderNodeToOutput(node.staticNode, staticOutput, { - skipStaticElements: false - }); - } + let staticOutput; - const {output: generatedOutput, height: outputHeight} = output.get(); + if (node.staticNode?.yogaNode) { + staticOutput = new Output({ + width: node.staticNode.yogaNode.getComputedWidth(), + height: node.staticNode.yogaNode.getComputedHeight() + }); - return { - output: generatedOutput, - outputHeight, - // Newline at the end is needed, because static output doesn't have one, so - // interactive output will override last line of static output - staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '' - }; + renderNodeToOutput(node.staticNode, staticOutput, { + skipStaticElements: false + }); } + const {output: generatedOutput, height: outputHeight} = output.get(); + return { - output: '', - outputHeight: 0, - staticOutput: '' + output: generatedOutput, + outputHeight, + // Newline at the end is needed, because static output doesn't have one, so + // interactive output will override last line of static output + staticOutput: staticOutput ? `${staticOutput.get().output}\n` : '' }; + } + + return { + output: '', + outputHeight: 0, + staticOutput: '' }; }; diff --git a/test/components.tsx b/test/components.tsx index ccbfe93cb..27cdd0ddb 100644 --- a/test/components.tsx +++ b/test/components.tsx @@ -15,6 +15,7 @@ import { useStdin, render } from '../src'; +import createStdout from './helpers/create-stdout'; test('text', t => { const output = renderToString(Hello World); @@ -191,10 +192,7 @@ test('fail when text node is not within component', t => { }); test('remesure text dimensions on text change', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const {rerender} = render( @@ -309,10 +307,7 @@ test('static output', t => { }); test('skip previous output when rendering new static output', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const Dynamic: FC<{items: string[]}> = ({items}) => ( {item => {item}} @@ -337,10 +332,7 @@ test('ensure wrap-ansi doesn’t trim leading whitespace', t => { }); test('replace child node with text', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const Dynamic = ({replace}) => ( {replace ? 'x' : test} @@ -359,10 +351,7 @@ test('replace child node with text', t => { // See https://github.com/vadimdemedes/ink/issues/145 test('disable raw mode when all input components are unmounted', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const stdin = new EventEmitter(); stdin.setEncoding = () => {}; @@ -427,10 +416,7 @@ test('disable raw mode when all input components are unmounted', t => { }); test('setRawMode() should throw if raw mode is not supported', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const stdin = new EventEmitter(); stdin.setEncoding = () => {}; @@ -486,10 +472,7 @@ test('setRawMode() should throw if raw mode is not supported', t => { }); test('render different component based on whether stdin is a TTY or not', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const stdin = new EventEmitter(); stdin.setEncoding = () => {}; @@ -578,10 +561,7 @@ test('render all frames if CI environment variable equals false', async t => { }); test('reset prop when it’s removed from the element', t => { - const stdout = { - write: spy(), - columns: 100 - }; + const stdout = createStdout(); const Dynamic = ({remove}) => ( { }); test('catch and display error', t => { - const stdout = { - columns: 100, - write: spy() - }; + const stdout = createStdout(); const Test = () => { throw new Error('Oh no'); @@ -34,17 +31,17 @@ test('catch and display error', t => { '', ' ERROR Oh no', '', - ' test/errors.tsx:26:9', + ' test/errors.tsx:23:9', '', - ' 23: };', - ' 24:', - ' 25: const Test = () => {', - " 26: throw new Error('Oh no');", - ' 27: };', - ' 28:', - ' 29: render(, {stdout});', + ' 20: const stdout = createStdout();', + ' 21:', + ' 22: const Test = () => {', + " 23: throw new Error('Oh no');", + ' 24: };', + ' 25:', + ' 26: render(, {stdout});', '', - ' - Test (test/errors.tsx:26:9)' + ' - Test (test/errors.tsx:23:9)' ] ); }); diff --git a/test/focus.tsx b/test/focus.tsx index 827ead267..65ffeb064 100644 --- a/test/focus.tsx +++ b/test/focus.tsx @@ -5,11 +5,7 @@ import delay from 'delay'; import test from 'ava'; import {spy} from 'sinon'; import {render, Box, Text, useFocus, useFocusManager} from '..'; - -const createStdout = () => ({ - write: spy(), - columns: 100 -}); +import createStdout from './helpers/create-stdout'; const createStdin = () => { const stdin = new EventEmitter(); diff --git a/test/helpers/create-stdout.ts b/test/helpers/create-stdout.ts new file mode 100644 index 000000000..5581e41f7 --- /dev/null +++ b/test/helpers/create-stdout.ts @@ -0,0 +1,19 @@ +import EventEmitter from 'events'; +import {spy} from 'sinon'; + +// Fake process.stdout +interface Stream extends EventEmitter { + output: string; + columns: number; + write(str: string): void; + get(): string; +} + +export default (columns?: number): Stream => { + const stdout = new EventEmitter(); + stdout.columns = columns ?? 100; + stdout.write = spy(); + stdout.get = () => stdout.write.lastCall.args[0]; + + return stdout; +}; diff --git a/test/helpers/render-to-string.ts b/test/helpers/render-to-string.ts index 9196439aa..b99d633b7 100644 --- a/test/helpers/render-to-string.ts +++ b/test/helpers/render-to-string.ts @@ -1,38 +1,17 @@ import {render} from '../../src'; - -// Fake process.stdout -interface Stream { - output: string; - columns: number; - write(str: string): void; - get(): string; -} - -const createStream: (options: {columns: number}) => Stream = ({columns}) => { - let output = ''; - return { - output, - columns, - write(str: string) { - output = str; - }, - get() { - return output; - } - }; -}; +import createStdout from './create-stdout'; export const renderToString: ( node: JSX.Element, options?: {columns: number} ) => string = (node, options = {columns: 100}) => { - const stream = createStream(options); + const stdout = createStdout(options.columns); render(node, { // @ts-ignore - stdout: stream, + stdout, debug: true }); - return stream.get(); + return stdout.get(); }; diff --git a/test/reconciler.tsx b/test/reconciler.tsx index a3191053d..df46ba555 100644 --- a/test/reconciler.tsx +++ b/test/reconciler.tsx @@ -1,13 +1,8 @@ import React, {Suspense} from 'react'; import test from 'ava'; import chalk from 'chalk'; -import {spy} from 'sinon'; import {Box, Text, render} from '../src'; - -const createStdout = () => ({ - write: spy(), - columns: 100 -}); +import createStdout from './helpers/create-stdout'; test('update child', t => { const Test = ({update}) => {update ? 'B' : 'A'}; diff --git a/test/render.tsx b/test/render.tsx index e1404135c..f15cab26a 100644 --- a/test/render.tsx +++ b/test/render.tsx @@ -1,7 +1,13 @@ +delete process.env.CI; +import React from 'react'; import {serial as test} from 'ava'; import {spawn} from 'node-pty'; import ansiEscapes from 'ansi-escapes'; import stripAnsi from 'strip-ansi'; +import boxen from 'boxen'; +import delay from 'delay'; +import {render, Box, Text} from '../src'; +import createStdout from './helpers/create-stdout'; const term = (fixture: string, args: string[] = []) => { let resolve: (value?: unknown) => void; @@ -13,14 +19,11 @@ const term = (fixture: string, args: string[] = []) => { reject = reject2; }); - const env = {...process.env}; - delete env.CI; - const ps = spawn('ts-node', [`./fixtures/${fixture}.tsx`, ...args], { name: 'xterm-color', cols: 100, cwd: __dirname, - env + env: process.env }); const result = { @@ -108,3 +111,34 @@ test('intercept console methods and display result above output', async t => { 'First log\r\nHello World\r\nSecond log\r\n' ]); }); + +test('rerender on resize', async t => { + const stdout = createStdout(10); + + const Test = () => ( + + Test + + ); + + const {unmount} = render(, {stdout}); + + t.is( + stripAnsi(stdout.write.firstCall.args[0]), + boxen('Test'.padEnd(8), {borderStyle: 'round'}) + '\n' + ); + + t.is(stdout.listeners('resize').length, 1); + + stdout.columns = 8; + stdout.emit('resize'); + await delay(100); + + t.is( + stripAnsi(stdout.write.lastCall.args[0]), + boxen('Test'.padEnd(6), {borderStyle: 'round'}) + '\n' + ); + + unmount(); + t.is(stdout.listeners('resize').length, 0); +});