From e10c1cbbc23f8070f497249ad8f7d67f89ad0614 Mon Sep 17 00:00:00 2001 From: knoxiboy Date: Sat, 1 Aug 2026 20:34:46 +0530 Subject: [PATCH] fix: Fix Text Wrapping & Multi-Byte Character Boundary Calculation on Window Resize (#3339) --- packages/core/src/utils/textWrap.ts | 57 +++++++++++++++++++++++++++++ packages/core/test/textWrap.test.ts | 31 ++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 packages/core/src/utils/textWrap.ts create mode 100644 packages/core/test/textWrap.test.ts diff --git a/packages/core/src/utils/textWrap.ts b/packages/core/src/utils/textWrap.ts new file mode 100644 index 000000000..2da7f73a6 --- /dev/null +++ b/packages/core/src/utils/textWrap.ts @@ -0,0 +1,57 @@ +import { stringWidth } from './unicode'; + +export interface WrapOptions { + hard?: boolean; + trim?: boolean; +} + +/** + * Wrap text into lines of specified maximum visual column width. + * Preserves multi-byte Unicode emoji and CJK character boundaries without splitting (#3339). + */ +export function wrapTextWithWidth( + text: string, + maxWidth: number, + options: WrapOptions = {} +): string[] { + if (!text || maxWidth <= 0) return []; + + // Segment text into grapheme clusters using Intl.Segmenter if available + let clusters: string[] = []; + if (typeof Intl !== 'undefined' && Intl.Segmenter) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); + clusters = Array.from(segmenter.segment(text)).map((s) => s.segment); + } else { + clusters = Array.from(text); + } + + const lines: string[] = []; + let currentLine = ''; + let currentLineWidth = 0; + + for (const cluster of clusters) { + if (cluster === '\n') { + lines.push(options.trim ? currentLine.trimEnd() : currentLine); + currentLine = ''; + currentLineWidth = 0; + continue; + } + + const clusterWidth = stringWidth(cluster); + + if (currentLineWidth + clusterWidth > maxWidth && currentLine.length > 0) { + lines.push(options.trim ? currentLine.trimEnd() : currentLine); + currentLine = cluster; + currentLineWidth = clusterWidth; + } else { + currentLine += cluster; + currentLineWidth += clusterWidth; + } + } + + if (currentLine.length > 0 || text.endsWith('\n')) { + lines.push(options.trim ? currentLine.trimEnd() : currentLine); + } + + return lines; +} diff --git a/packages/core/test/textWrap.test.ts b/packages/core/test/textWrap.test.ts new file mode 100644 index 000000000..337f9cab0 --- /dev/null +++ b/packages/core/test/textWrap.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'bun:test'; +import { wrapTextWithWidth } from '../src/utils/textWrap'; + +describe('wrapTextWithWidth Unit Tests', () => { + it('should wrap basic ASCII text without breaking words unnecessarily', () => { + const lines = wrapTextWithWidth('Hello World Test', 12, { trim: true }); + expect(lines.length).toBeGreaterThan(1); + expect(lines[0]).toBe('Hello World'); + }); + + it('should preserve multi-byte emoji boundaries when wrapping', () => { + const text = '🚀🎉⭐🔥💡'; + const lines = wrapTextWithWidth(text, 4); + // Each emoji has a visual width of 2 columns. Max width 4 holds 2 emojis per line. + expect(lines.length).toBe(3); + expect(lines[0]).toBe('🚀🎉'); + expect(lines[1]).toBe('⭐🔥'); + expect(lines[2]).toBe('💡'); + }); + + it('should handle newline characters properly', () => { + const text = 'Line 1\nLine 2'; + const lines = wrapTextWithWidth(text, 20); + expect(lines).toEqual(['Line 1', 'Line 2']); + }); + + it('should handle zero or negative maxWidth gracefully', () => { + expect(wrapTextWithWidth('Test', 0)).toEqual([]); + expect(wrapTextWithWidth('', 10)).toEqual([]); + }); +});