Merge pull request #67 from y574444354/fix/tab-protection-measured-text
fix: protect tabs from wrap-ansi expansion in MeasuredText
This commit is contained in:
commit
021ce31f31
|
|
@ -1290,11 +1290,30 @@ export class MeasuredText {
|
|||
}
|
||||
|
||||
private measureWrappedText(): WrappedLine[] {
|
||||
const wrappedText = wrapAnsi(this.text, this.columns, {
|
||||
// Protect tabs from being expanded by npm wrap-ansi in Node.js dist builds.
|
||||
// Bun.wrapAnsi preserves tabs, but npm wrap-ansi expands them to spaces,
|
||||
// which breaks the indexOf-based offset recovery below.
|
||||
const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063']
|
||||
const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find(
|
||||
(ch) => !this.text.includes(ch),
|
||||
)
|
||||
const hasTabs = this.text.includes('\t')
|
||||
|
||||
const textForWrap =
|
||||
hasTabs && tabPlaceholder
|
||||
? this.text.replaceAll('\t', tabPlaceholder)
|
||||
: this.text
|
||||
|
||||
const wrappedTextRaw = wrapAnsi(textForWrap, this.columns, {
|
||||
hard: true,
|
||||
trim: false,
|
||||
})
|
||||
|
||||
const wrappedText =
|
||||
hasTabs && tabPlaceholder
|
||||
? wrappedTextRaw.replaceAll(tabPlaceholder, '\t')
|
||||
: wrappedTextRaw
|
||||
|
||||
const wrappedLines: WrappedLine[] = []
|
||||
let searchOffset = 0
|
||||
let lastNewLinePos = -1
|
||||
|
|
|
|||
89
src/utils/__tests__/Cursor.test.ts
Normal file
89
src/utils/__tests__/Cursor.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, test, expect } from 'bun:test'
|
||||
import { Cursor } from '../Cursor'
|
||||
|
||||
describe('MeasuredText tab protection', () => {
|
||||
test('Cursor.fromText with tabs does not throw', () => {
|
||||
const cursor = Cursor.fromText('a\tb', 80)
|
||||
expect(cursor).toBeTruthy()
|
||||
expect(cursor.text).toBe('a\tb')
|
||||
})
|
||||
|
||||
test('tab text preserves original tabs after measurement', () => {
|
||||
const cursor = Cursor.fromText('hello\tworld', 80)
|
||||
expect(cursor.text).toBe('hello\tworld')
|
||||
// Trigger measurement
|
||||
cursor.getPosition()
|
||||
expect(cursor.text).toBe('hello\tworld')
|
||||
})
|
||||
|
||||
test('multi-line tab text works', () => {
|
||||
const cursor = Cursor.fromText('foo\n\tbar', 80)
|
||||
expect(cursor.text).toBe('foo\n\tbar')
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
})
|
||||
|
||||
test('tabs in narrow columns (triggering visual wrap) does not throw', () => {
|
||||
// 10 cols is narrow enough that tabs + text will wrap
|
||||
const cursor = Cursor.fromText('x\t\thello world\tfoo', 10)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
expect(cursor.text).toBe('x\t\thello world\tfoo')
|
||||
})
|
||||
|
||||
test('getPosition returns valid coordinates for tab text', () => {
|
||||
const cursor = Cursor.fromText('a\tb\tc', 80)
|
||||
const pos = cursor.getPosition()
|
||||
expect(pos.line).toBeGreaterThanOrEqual(0)
|
||||
expect(pos.column).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
test('cursor movement on tab text does not throw', () => {
|
||||
const cursor = Cursor.fromText('abc\tdef\tghi', 80)
|
||||
// Move right through tab
|
||||
const right1 = cursor.right()
|
||||
expect(() => right1.getPosition()).not.toThrow()
|
||||
const right2 = right1.right()
|
||||
expect(() => right2.getPosition()).not.toThrow()
|
||||
// Move to end
|
||||
const end = cursor.endOfFile()
|
||||
expect(() => end.getPosition()).not.toThrow()
|
||||
// Move back
|
||||
expect(() => end.left().getPosition()).not.toThrow()
|
||||
})
|
||||
|
||||
test('tab text in very narrow views (1 col) does not throw', () => {
|
||||
const cursor = Cursor.fromText('a\tb', 1)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
})
|
||||
|
||||
test('text with only tabs does not throw', () => {
|
||||
const cursor = Cursor.fromText('\t\t', 80)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
expect(cursor.text).toBe('\t\t')
|
||||
})
|
||||
|
||||
test('mixed tabs and newlines', () => {
|
||||
const cursor = Cursor.fromText('a\tb\nc\td\ne\tf', 80)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
// Move to last line
|
||||
const eof = cursor.endOfFile()
|
||||
expect(eof.text).toBe('a\tb\nc\td\ne\tf')
|
||||
})
|
||||
|
||||
test('placeholder characters in text are handled gracefully', () => {
|
||||
// Text already contains \u2060 (word joiner)
|
||||
const textWithPlaceholder = 'ab\u2060\tc'
|
||||
const cursor = Cursor.fromText(textWithPlaceholder, 80)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
expect(cursor.text).toBe(textWithPlaceholder)
|
||||
})
|
||||
|
||||
test('text with all placeholder candidates still works', () => {
|
||||
// Text contains all four zero-width placeholder candidates
|
||||
const allPlaceholders = 'a\u2060\u2061\u2062\u2063\tb'
|
||||
const cursor = Cursor.fromText(allPlaceholders, 80)
|
||||
expect(() => cursor.getPosition()).not.toThrow()
|
||||
expect(cursor.text).toBe(allPlaceholders)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user