Merge pull request #82 from IronRookieCoder/fix/issue-41
fix(cursor): handle ANSI wrapped line offset recovery
This commit is contained in:
commit
0007879cf4
|
|
@ -1232,7 +1232,9 @@ export class MeasuredText {
|
||||||
public stringIndexToDisplayWidth(text: string, index: number): number {
|
public stringIndexToDisplayWidth(text: string, index: number): number {
|
||||||
if (index <= 0) return 0
|
if (index <= 0) return 0
|
||||||
if (index >= text.length) return stringWidth(text)
|
if (index >= text.length) return stringWidth(text)
|
||||||
return stringWidth(text.substring(0, index))
|
if (!this.hasAnsiSequence(text))
|
||||||
|
return stringWidth(text.substring(0, index))
|
||||||
|
return this.stringIndexToDisplayWidthWithAnsi(text, index)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert display width to string index
|
// Convert display width to string index
|
||||||
|
|
@ -1245,6 +1247,10 @@ export class MeasuredText {
|
||||||
return this.offsetAtDisplayWidth(targetWidth)
|
return this.offsetAtDisplayWidth(targetWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.hasAnsiSequence(text)) {
|
||||||
|
return this.offsetAtDisplayWidthInText(text, targetWidth)
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise compute on the fly
|
// Otherwise compute on the fly
|
||||||
let currentWidth = 0
|
let currentWidth = 0
|
||||||
let currentOffset = 0
|
let currentOffset = 0
|
||||||
|
|
@ -1269,6 +1275,10 @@ export class MeasuredText {
|
||||||
private offsetAtDisplayWidth(targetWidth: number): number {
|
private offsetAtDisplayWidth(targetWidth: number): number {
|
||||||
if (targetWidth <= 0) return 0
|
if (targetWidth <= 0) return 0
|
||||||
|
|
||||||
|
if (this.hasAnsiSequence(this.text)) {
|
||||||
|
return this.offsetAtDisplayWidthInText(this.text, targetWidth)
|
||||||
|
}
|
||||||
|
|
||||||
let currentWidth = 0
|
let currentWidth = 0
|
||||||
const boundaries = this.getGraphemeBoundaries()
|
const boundaries = this.getGraphemeBoundaries()
|
||||||
|
|
||||||
|
|
@ -1289,13 +1299,67 @@ export class MeasuredText {
|
||||||
return this.text.length
|
return this.text.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private stringIndexToDisplayWidthWithAnsi(
|
||||||
|
text: string,
|
||||||
|
index: number,
|
||||||
|
): number {
|
||||||
|
const endIndex = Math.min(index, text.length)
|
||||||
|
let offset = 0
|
||||||
|
let currentWidth = 0
|
||||||
|
|
||||||
|
while (offset < endIndex) {
|
||||||
|
const ansiEnd = this.getAnsiSequenceEndInText(text, offset)
|
||||||
|
if (ansiEnd !== null) {
|
||||||
|
if (ansiEnd > endIndex) break
|
||||||
|
offset = ansiEnd
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = this.getGraphemeAtOffsetInText(text, offset)
|
||||||
|
if (segment.length === 0) break
|
||||||
|
currentWidth += stringWidth(segment)
|
||||||
|
offset += segment.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
private offsetAtDisplayWidthInText(
|
||||||
|
text: string,
|
||||||
|
targetWidth: number,
|
||||||
|
): number {
|
||||||
|
if (targetWidth <= 0) return 0
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
let currentWidth = 0
|
||||||
|
|
||||||
|
while (offset < text.length) {
|
||||||
|
const ansiEnd = this.getAnsiSequenceEndInText(text, offset)
|
||||||
|
if (ansiEnd !== null) {
|
||||||
|
offset = ansiEnd
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = this.getGraphemeAtOffsetInText(text, offset)
|
||||||
|
if (segment.length === 0) break
|
||||||
|
|
||||||
|
const segmentWidth = stringWidth(segment)
|
||||||
|
if (currentWidth + segmentWidth > targetWidth) break
|
||||||
|
|
||||||
|
currentWidth += segmentWidth
|
||||||
|
offset += segment.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
private measureWrappedText(): WrappedLine[] {
|
private measureWrappedText(): WrappedLine[] {
|
||||||
// Protect tabs from being expanded by npm wrap-ansi in Node.js dist builds.
|
// 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,
|
// Bun.wrapAnsi preserves tabs, but npm wrap-ansi expands them to spaces,
|
||||||
// which breaks the indexOf-based offset recovery below.
|
// which breaks the indexOf-based offset recovery below.
|
||||||
const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063']
|
const TAB_PLACEHOLDER_CANDIDATES = ['\u2060', '\u2061', '\u2062', '\u2063']
|
||||||
const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find(
|
const tabPlaceholder = TAB_PLACEHOLDER_CANDIDATES.find(
|
||||||
(ch) => !this.text.includes(ch),
|
ch => !this.text.includes(ch),
|
||||||
)
|
)
|
||||||
const hasTabs = this.text.includes('\t')
|
const hasTabs = this.text.includes('\t')
|
||||||
|
|
||||||
|
|
@ -1353,17 +1417,31 @@ export class MeasuredText {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// For non-blank lines, find the text in this.text
|
// For non-blank lines, find the text in this.text. wrap-ansi may add
|
||||||
const startOffset = this.text.indexOf(text, searchOffset)
|
// style reset/reopen sequences at visual wrap boundaries, so the
|
||||||
|
// wrapped line can be a display-equivalent string that is not a literal
|
||||||
|
// slice of the input. Fall back to advancing through the original text
|
||||||
|
// by display width instead of crashing the input renderer.
|
||||||
|
let startOffset = this.text.indexOf(text, searchOffset)
|
||||||
|
let lineText = text
|
||||||
|
|
||||||
if (startOffset === -1) {
|
if (startOffset === -1) {
|
||||||
throw new Error('Failed to find wrapped line in text')
|
startOffset = this.findFallbackStartOffset(
|
||||||
|
searchOffset,
|
||||||
|
lastNewLinePos,
|
||||||
|
)
|
||||||
|
const endOffset = this.findOffsetAtDisplayWidth(
|
||||||
|
startOffset,
|
||||||
|
stringWidth(text),
|
||||||
|
)
|
||||||
|
lineText = this.text.slice(startOffset, endOffset)
|
||||||
|
searchOffset = endOffset
|
||||||
|
} else {
|
||||||
|
searchOffset = startOffset + text.length
|
||||||
}
|
}
|
||||||
|
|
||||||
searchOffset = startOffset + text.length
|
|
||||||
|
|
||||||
// Check if this line ends with a newline in this.text
|
// Check if this line ends with a newline in this.text
|
||||||
const potentialNewlinePos = startOffset + text.length
|
const potentialNewlinePos = startOffset + lineText.length
|
||||||
const endsWithNewline =
|
const endsWithNewline =
|
||||||
potentialNewlinePos < this.text.length &&
|
potentialNewlinePos < this.text.length &&
|
||||||
this.text[potentialNewlinePos] === '\n'
|
this.text[potentialNewlinePos] === '\n'
|
||||||
|
|
@ -1374,7 +1452,7 @@ export class MeasuredText {
|
||||||
|
|
||||||
wrappedLines.push(
|
wrappedLines.push(
|
||||||
new WrappedLine(
|
new WrappedLine(
|
||||||
text,
|
lineText,
|
||||||
startOffset,
|
startOffset,
|
||||||
isPrecededByNewline(startOffset),
|
isPrecededByNewline(startOffset),
|
||||||
endsWithNewline,
|
endsWithNewline,
|
||||||
|
|
@ -1386,6 +1464,116 @@ export class MeasuredText {
|
||||||
return wrappedLines
|
return wrappedLines
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private findFallbackStartOffset(
|
||||||
|
searchOffset: number,
|
||||||
|
lastNewLinePos: number,
|
||||||
|
): number {
|
||||||
|
let startOffset = Math.max(searchOffset, lastNewLinePos + 1)
|
||||||
|
|
||||||
|
while (startOffset < this.text.length && this.text[startOffset] === '\n') {
|
||||||
|
startOffset++
|
||||||
|
}
|
||||||
|
|
||||||
|
return startOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
private findOffsetAtDisplayWidth(
|
||||||
|
startOffset: number,
|
||||||
|
targetWidth: number,
|
||||||
|
): number {
|
||||||
|
if (targetWidth <= 0) return startOffset
|
||||||
|
|
||||||
|
let offset = startOffset
|
||||||
|
let currentWidth = 0
|
||||||
|
|
||||||
|
while (offset < this.text.length) {
|
||||||
|
if (this.text[offset] === '\n') break
|
||||||
|
|
||||||
|
const ansiEnd = this.getAnsiSequenceEndInText(this.text, offset)
|
||||||
|
if (ansiEnd !== null) {
|
||||||
|
offset = ansiEnd
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = this.getGraphemeAtOffset(offset)
|
||||||
|
if (segment.length === 0) break
|
||||||
|
|
||||||
|
currentWidth += stringWidth(segment)
|
||||||
|
offset += segment.length
|
||||||
|
|
||||||
|
if (currentWidth >= targetWidth) break
|
||||||
|
}
|
||||||
|
|
||||||
|
return offset
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGraphemeAtOffset(offset: number): string {
|
||||||
|
return this.getGraphemeAtOffsetInText(this.text, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getGraphemeAtOffsetInText(text: string, offset: number): string {
|
||||||
|
const iterator = getGraphemeSegmenter()
|
||||||
|
.segment(text.slice(offset))
|
||||||
|
[Symbol.iterator]()
|
||||||
|
const next = iterator.next()
|
||||||
|
return next.done ? '' : next.value.segment
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasAnsiSequence(text: string): boolean {
|
||||||
|
return (
|
||||||
|
text.includes('\x1b') || text.includes('\x9b') || text.includes('\x9d')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAnsiSequenceEndInText(
|
||||||
|
text: string,
|
||||||
|
offset: number,
|
||||||
|
): number | null {
|
||||||
|
const first = text.charCodeAt(offset)
|
||||||
|
if (first === 0x9b) {
|
||||||
|
return this.findControlSequenceEnd(text, offset + 1)
|
||||||
|
}
|
||||||
|
if (first === 0x9d) {
|
||||||
|
return this.findStringControlEnd(text, offset + 1)
|
||||||
|
}
|
||||||
|
if (first !== 0x1b) return null
|
||||||
|
|
||||||
|
const second = text.charCodeAt(offset + 1)
|
||||||
|
if (Number.isNaN(second)) return null
|
||||||
|
|
||||||
|
if (second === 0x5b) {
|
||||||
|
return this.findControlSequenceEnd(text, offset + 2)
|
||||||
|
}
|
||||||
|
if (second === 0x5d) {
|
||||||
|
return this.findStringControlEnd(text, offset + 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(offset + 2, text.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
private findControlSequenceEnd(text: string, offset: number): number {
|
||||||
|
let i = offset
|
||||||
|
while (i < text.length) {
|
||||||
|
const code = text.charCodeAt(i)
|
||||||
|
if (code >= 0x40 && code <= 0x7e) return i + 1
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return text.length
|
||||||
|
}
|
||||||
|
|
||||||
|
private findStringControlEnd(text: string, offset: number): number {
|
||||||
|
let i = offset
|
||||||
|
while (i < text.length) {
|
||||||
|
const code = text.charCodeAt(i)
|
||||||
|
if (code === 0x07) return i + 1
|
||||||
|
if (code === 0x1b && text.charCodeAt(i + 1) === 0x5c) {
|
||||||
|
return Math.min(i + 2, text.length)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return text.length
|
||||||
|
}
|
||||||
|
|
||||||
public getWrappedText(): WrappedText {
|
public getWrappedText(): WrappedText {
|
||||||
return this.wrappedLines.map(line =>
|
return this.wrappedLines.map(line =>
|
||||||
line.isPrecededByNewline ? line.text : line.text.trimStart(),
|
line.isPrecededByNewline ? line.text : line.text.trimStart(),
|
||||||
|
|
|
||||||
|
|
@ -86,4 +86,46 @@ describe('MeasuredText tab protection', () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MeasuredText wrapped line recovery', () => {
|
||||||
|
test('styled long input does not throw when wrap-ansi inserts reset codes', () => {
|
||||||
|
const cursor = Cursor.fromText(
|
||||||
|
'foo\x1b[31mbarbaz\x1b[39mqux',
|
||||||
|
6,
|
||||||
|
'foo\x1b[31mbarbaz\x1b[39mqux'.length,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(() => cursor.getPosition()).not.toThrow()
|
||||||
|
expect(() => cursor.render(' ', '', text => text)).not.toThrow()
|
||||||
|
expect(cursor.getPosition().line).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('styled long input keeps end offset on final wrapped line', () => {
|
||||||
|
const text = 'foo\x1b[31mbarbaz\x1b[39mqux'
|
||||||
|
const cursor = Cursor.fromText(text, 6, text.length)
|
||||||
|
const position = cursor.getPosition()
|
||||||
|
|
||||||
|
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
|
||||||
|
text.length,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('styled fallback line with reset code maps final position to EOF', () => {
|
||||||
|
const text = 'hello\x1b[31m世界abc\x1b[39mdef'
|
||||||
|
const cursor = Cursor.fromText(text, 6, text.length)
|
||||||
|
const position = cursor.getPosition()
|
||||||
|
|
||||||
|
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
|
||||||
|
text.length,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('OSC styled fallback line maps final position to EOF', () => {
|
||||||
|
const text = 'foo\x1b]8;;https://example.com\x07barbaz\x1b]8;;;;\x07qux'
|
||||||
|
const cursor = Cursor.fromText(text, 4, text.length)
|
||||||
|
const position = cursor.getPosition()
|
||||||
|
|
||||||
|
expect(cursor.measuredText.getOffsetFromPosition(position)).toBe(
|
||||||
|
text.length,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user