diff --git a/src/ink.tsx b/src/ink.tsx index 1162777a9..bce2d5497 100644 --- a/src/ink.tsx +++ b/src/ink.tsx @@ -175,8 +175,10 @@ export default class Ink { if (outputHeight >= this.options.stdout.rows) { this.options.stdout.write( - ansiEscapes.clearTerminal + this.fullStaticOutput + output + ansiEscapes.clearTerminal + this.fullStaticOutput ); + + this.log(output, {force: true, erase: false}); this.lastOutput = output; return; } diff --git a/src/log-update.ts b/src/log-update.ts index a38e565a3..2b46a0322 100644 --- a/src/log-update.ts +++ b/src/log-update.ts @@ -5,7 +5,7 @@ import cliCursor from 'cli-cursor'; export type LogUpdate = { clear: () => void; done: () => void; - (str: string): void; + (str: string, options?: {force?: boolean; erase?: boolean}): void; }; const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => { @@ -13,19 +13,25 @@ const create = (stream: Writable, {showCursor = false} = {}): LogUpdate => { let previousOutput = ''; let hasHiddenCursor = false; - const render = (str: string) => { + const render = ( + str: string, + {force = false, erase = true}: {force?: boolean; erase?: boolean} = {} + ) => { if (!showCursor && !hasHiddenCursor) { cliCursor.hide(); hasHiddenCursor = true; } const output = str + '\n'; - if (output === previousOutput) { + if (output === previousOutput && !force) { return; } previousOutput = output; - stream.write(ansiEscapes.eraseLines(previousLineCount) + output); + + const eraser = erase ? ansiEscapes.eraseLines(previousLineCount) : ''; + stream.write(eraser + output); + previousLineCount = output.split('\n').length; }; diff --git a/test/fixtures/erase-once-with-static.tsx b/test/fixtures/erase-once-with-static.tsx new file mode 100644 index 000000000..9dfac4d72 --- /dev/null +++ b/test/fixtures/erase-once-with-static.tsx @@ -0,0 +1,33 @@ +import process from 'node:process'; +import React, {useState} from 'react'; +import {Box, Static, Text, render, useInput} from '../../src/index.js'; + +function Test() { + const [fullHeight, setFullHeight] = useState(true); + + useInput( + input => { + if (input === 'x') { + setFullHeight(false); + } + }, + {isActive: fullHeight} + ); + + return ( + <> + + {item => {item}} + + + + A + B + {fullHeight && C} + + + ); +} + +process.stdout.rows = Number(process.argv[2]); +render(); diff --git a/test/fixtures/erase-once.tsx b/test/fixtures/erase-once.tsx new file mode 100644 index 000000000..267702c0e --- /dev/null +++ b/test/fixtures/erase-once.tsx @@ -0,0 +1,27 @@ +import process from 'node:process'; +import React, {useState} from 'react'; +import {Box, Text, render, useInput} from '../../src/index.js'; + +function Test() { + const [fullHeight, setFullHeight] = useState(true); + + useInput( + input => { + if (input === 'x') { + setFullHeight(false); + } + }, + {isActive: fullHeight} + ); + + return ( + + A + B + {fullHeight && C} + + ); +} + +process.stdout.rows = Number(process.argv[2]); +render(); diff --git a/test/render.tsx b/test/render.tsx index 8ce8333bd..c89ea3f33 100644 --- a/test/render.tsx +++ b/test/render.tsx @@ -106,6 +106,54 @@ test.serial('erase screen', async t => { } }); +test.serial('erase screen once then continue rendering as usual', async t => { + const ps = term('erase-once', ['3']); + await delay(1000); + + t.true(ps.output.includes(ansiEscapes.clearTerminal)); + t.true(ps.output.includes('A')); + t.true(ps.output.includes('B')); + t.true(ps.output.includes('C')); + + ps.output = ''; + ps.write('x'); + + await ps.waitForExit(); + + t.false(ps.output.includes(ansiEscapes.clearTerminal)); + t.true(ps.output.includes(ansiEscapes.eraseLines(3))); + t.true(ps.output.includes('A')); + t.true(ps.output.includes('B')); + t.false(ps.output.includes('C')); +}); + +test.serial( + 'erase screen once then continue rendering as usual with present', + async t => { + const ps = term('erase-once-with-static', ['3']); + await delay(1000); + + t.true(ps.output.includes(ansiEscapes.clearTerminal)); + t.true(ps.output.includes('X')); + t.true(ps.output.includes('Y')); + t.true(ps.output.includes('Z')); + t.true(ps.output.includes('A')); + t.true(ps.output.includes('B')); + t.true(ps.output.includes('C')); + + ps.output = ''; + ps.write('x'); + + await ps.waitForExit(); + + t.false(ps.output.includes(ansiEscapes.clearTerminal)); + t.true(ps.output.includes(ansiEscapes.eraseLines(2))); + t.true(ps.output.includes('A')); + t.true(ps.output.includes('B')); + t.false(ps.output.includes('C')); + } +); + test.serial( 'erase screen where exists but interactive part is taller than viewport', async t => {