From 71e0165eca31689b60d73da906631df71e418291 Mon Sep 17 00:00:00 2001 From: Mayank Dhingra Date: Thu, 15 Oct 2026 17:25:24 -0400 Subject: [PATCH] fix: keep inline span text separated Require a separating space when the next row has visible text. Decide whether it fits after assembling the complete row, so empty leading columns cannot hide the span before that check. Preserve empty rows and spans, and append multi-column rows only once when wrapping is disabled. Add plain, ANSI and wide-text regressions. Closes #88 Signed-off-by: Mayank Dhingra --- lib/index.ts | 18 ++++++++-------- test/cliui.mjs | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/index.ts b/lib/index.ts index 48dd22d9..623ae96d 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -180,14 +180,13 @@ export class UI { if (padding[right]) { str += ' '.repeat(padding[right]) } - - // if prior row is span, try to render the - // current row on the prior line. - if (r === 0 && lines.length > 0) { - str = this.renderInline(str, lines[lines.length - 1]) - } }) + // if prior row is span, try to render the complete row on the prior line. + if (r === 0 && lines.length > 0) { + str = this.renderInline(str, lines[lines.length - 1]) + } + // remove trailing whitespace. lines.push({ text: str.replace(/ +$/, ''), @@ -217,13 +216,16 @@ export class UI { return target + source } - if (leadingWhitespace < targetTextWidth) { + const sourceText = source.trimLeft() + // Keep separate pieces of visible text from running into each other. + const minimumGap = targetTextWidth > 0 && mixin.stringWidth(sourceText) > 0 ? 1 : 0 + if (leadingWhitespace < targetTextWidth + minimumGap) { return source } previousLine.hidden = true - return target.trimRight() + ' '.repeat(leadingWhitespace - targetTextWidth) + source.trimLeft() + return target.trimRight() + ' '.repeat(leadingWhitespace - targetTextWidth) + sourceText } private rasterize (row: ColumnArray) { diff --git a/test/cliui.mjs b/test/cliui.mjs index b2d5aa02..e7cdb4de 100644 --- a/test/cliui.mjs +++ b/test/cliui.mjs @@ -279,6 +279,62 @@ describe('cliui', () => { }) describe('span', () => { + describe('span boundary', () => { + for (const [name, text] of [ + ['plain text', '1234567890'], + ['ANSI text', '\u001b[32m1234567890\u001b[39m'], + ['wide text', '漢字漢字漢'] + ]) { + it(`starts a new line when ${name} leaves no separating space`, () => { + const ui = cliui({ width: 20 }) + ui.span(text) + ui.div({ text: '[boolean]', padding: [0, 0, 0, 10] }) + + stripAnsi(ui.toString()).should.equal(`${stripAnsi(text)}\n [boolean]`) + }) + } + + it('appends text when one separating space fits', () => { + const ui = cliui({ width: 20 }) + ui.span('123456789') + ui.div({ text: '[boolean]', padding: [0, 0, 0, 10] }) + + ui.toString().should.equal('123456789 [boolean]') + }) + + it('checks the boundary after assembling all columns', () => { + const ui = cliui({ width: 20 }) + ui.span('1234567890') + ui.div({ text: '', width: 10 }, { text: '[boolean]', width: 10 }) + + ui.toString().should.equal('1234567890\n [boolean]') + }) + + it('appends a multi-column row only once when wrapping is disabled', () => { + const ui = cliui({ width: 20, wrap: false }) + ui.span('prefix') + ui.div('first', 'second') + + ui.toString().should.equal('prefixfirstsecond') + }) + + it('does not introduce a blank line for an empty span', () => { + const ui = cliui({ width: 20 }) + ui.span('') + ui.div('[boolean]') + + ui.toString().should.equal('[boolean]') + }) + + it('does not introduce a blank line for an empty following column', () => { + const ui = cliui({ width: 20 }) + ui.span('1234567890') + ui.div({ text: '', padding: [0, 0, 0, 10] }) + + ui.toString().should.equal('1234567890') + }) + }) + it('appends the next row to the end of the prior row if it fits', () => { const ui = cliui({ width: 40