Skip to content

fix: Fix Text Wrapping & Multi-Byte Character Boundary Calculation on Window Resize (#3339) - #3362

Open
knoxiboy wants to merge 1 commit into
Karanjot786:mainfrom
knoxiboy:3339-bug-fix-text-wrapping-multi-byte-character-boundary
Open

fix: Fix Text Wrapping & Multi-Byte Character Boundary Calculation on Window Resize (#3339)#3362
knoxiboy wants to merge 1 commit into
Karanjot786:mainfrom
knoxiboy:3339-bug-fix-text-wrapping-multi-byte-character-boundary

Conversation

@knoxiboy

@knoxiboy knoxiboy commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Description

Fixes text wrapping boundary calculations for multi-byte Unicode and emoji characters when terminal window dimensions resize.

Related Issue

Closes #3339

Which package(s)?

@termuijs/core

Type of Change

  • 🐛 Bug fix (type:bug)
  • ✨ Feature (type:feature)
  • 📝 Docs (type:docs)
  • 🧪 Tests (type:testing)
  • ♻️ Refactor (type:refactor)
  • 🎨 Design / UX (type:design)
  • ♿ Accessibility (type:accessibility)
  • ⚡ Performance (type:performance)
  • 🔧 DevOps / CI (type:devops)
  • 🔒 Security (type:security)

Checklist

  • ⭐ You starred the repo. The needs-star check blocks your merge otherwise.
  • Tests pass locally: bun vitest run
  • Build passes: bun run build
  • Typecheck passes: bun run typecheck
  • You read CONTRIBUTING.md.
  • Your PR title follows type: short description.
  • Widget state mutators call markDirty() (if your change affects rendering).
  • No new any types without an inline comment explaining why.
  • No unrelated refactors bundled into this PR.

GSSoC 2026 Participation

  • You are a GSSoC 2026 contributor.
  • Your GSSoC profile: https://gssoc.girlscript.org/profile/knoxiboy

Screenshots / Recordings (UI changes)

Preserves multi-byte Unicode emoji character boundaries cleanly when wrapped across terminal column bounds.

Notes for the Reviewer

Zero external dependencies; uses native string segmenters and width measurement.

Summary by CodeRabbit

  • New Features

    • Added text wrapping based on visual width, including support for Unicode characters and emoji.
    • Preserves newline boundaries and supports optional trailing-whitespace trimming.
    • Handles empty or invalid input consistently.
  • Tests

    • Added coverage for word wrapping, emoji boundaries, newline handling, and empty-input behavior.

@knoxiboy
knoxiboy requested a review from Karanjot786 as a code owner August 1, 2026 15:05
Copilot AI review requested due to automatic review settings August 1, 2026 15:05

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added type:bug +10 pts. Bug fix. area:core @termuijs/core type:testing +10 pts. Tests. labels Aug 1, 2026
@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds wrapTextWithWidth for Unicode-aware text wrapping. The utility preserves grapheme clusters and newline boundaries, supports optional trailing-whitespace trimming, handles invalid widths and empty input, and includes focused unit tests.

Changes

Unicode text wrapping

Layer / File(s) Summary
Wrapping utility and validation
packages/core/src/utils/textWrap.ts, packages/core/test/textWrap.test.ts
Adds the public WrapOptions interface and wrapTextWithWidth function. The implementation tracks visual widths, preserves grapheme clusters and trailing empty lines, handles newlines and invalid inputs, and is covered by unit tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested labels: level:advanced

Suggested reviewers: karanjot786, copilot

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The change fixes Unicode width and grapheme boundaries, but it does not implement ANSI stripping, debounced resize handling, dynamic grid recalculation, or 20/40-column coverage. Implement the missing resize listener and grid recalculation, preserve ANSI codes while measuring visible width, and add regression tests at 20- and 40-column widths.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the text-wrapping and Unicode boundary fix for terminal resize handling.
Description check ✅ Passed The description includes the required sections, linked issue, package, change type, checklist, contributor details, and reviewer notes.
Out of Scope Changes check ✅ Passed The source and test changes remain within the text-wrapping and Unicode boundary objectives of issue #3339.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (1)
packages/core/test/textWrap.test.ts (1)

4-31: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing CJK and 20/40-column regression coverage requested by issue #3339.

The linked issue asks for regression coverage of multibyte text at 20- and 40-column widths, and for handling double-width CJK characters. This file only covers emoji (lines 11-19) at a width of 4. Add tests with CJK text (for example Chinese/Japanese characters, each width 2) wrapped at 20 and 40 columns to close the gap called out in the issue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/textWrap.test.ts` around lines 4 - 31, Add regression
tests in the wrapTextWithWidth test suite for double-width CJK text, covering
maxWidth values of 20 and 40 columns. Assert that characters are not split, each
line stays within the requested visual width, and the expected wrapping differs
appropriately between the two widths; keep the existing emoji coverage
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/core/src/utils/textWrap.ts`:
- Around line 3-6: The WrapOptions.hard setting is ignored by wrapTextWithWidth,
so soft wrapping does not preserve word boundaries. Update wrapTextWithWidth’s
wrap loop to scan backward for the last whitespace cluster before the width
limit when hard is falsy, while retaining strict cluster-width breaks when hard
is true; preserve existing behavior when no suitable boundary exists.
- Around line 19-26: Update the grapheme-cluster construction in the
text-wrapping utility to recognize ANSI escape sequences as zero-width atomic
tokens before or during segmentation. Preserve each complete sequence as one
token, exclude it from visible-width measurement, and ensure wrapping never
splits a sequence across lines while retaining it in the output.

In `@packages/core/test/textWrap.test.ts`:
- Around line 27-30: Update the test “should handle zero or negative maxWidth
gracefully” to add an assertion calling wrapTextWithWidth with a negative
maxWidth such as -5 and expecting an empty array, while preserving the existing
zero-width and empty-input assertions.

---

Nitpick comments:
In `@packages/core/test/textWrap.test.ts`:
- Around line 4-31: Add regression tests in the wrapTextWithWidth test suite for
double-width CJK text, covering maxWidth values of 20 and 40 columns. Assert
that characters are not split, each line stays within the requested visual
width, and the expected wrapping differs appropriately between the two widths;
keep the existing emoji coverage unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2b605f44-f545-44e7-ba39-c820000f99cf

📥 Commits

Reviewing files that changed from the base of the PR and between 48f63a1 and e10c1cb.

📒 Files selected for processing (2)
  • packages/core/src/utils/textWrap.ts
  • packages/core/test/textWrap.test.ts

Comment on lines +3 to +6
export interface WrapOptions {
hard?: boolean;
trim?: boolean;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

hard option has no effect; word-boundary preservation is coincidental.

WrapOptions.hard (line 4) is never read in wrapTextWithWidth. The wrap loop (lines 42-49) always breaks strictly on cumulative cluster width, with no lookback for a word boundary. Passing hard: true or hard: false (or omitting it) produces identical output.

This also means the "without breaking words unnecessarily" behavior exercised by the ASCII test in textWrap.test.ts (lines 5-9) only holds because the wrap point happens to land on a space in that specific input. For example, wrapping 'Hello Worldss Test' at width 12 would split 'Worldss' mid-word, since there is no word-boundary logic. Either implement soft-wrap behavior gated by hard (scan backward for the last whitespace cluster before the width limit when hard is falsy) or remove the unused option from the public interface until it is implemented.

Also applies to: 42-49

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/utils/textWrap.ts` around lines 3 - 6, The WrapOptions.hard
setting is ignored by wrapTextWithWidth, so soft wrapping does not preserve word
boundaries. Update wrapTextWithWidth’s wrap loop to scan backward for the last
whitespace cluster before the width limit when hard is falsy, while retaining
strict cluster-width breaks when hard is true; preserve existing behavior when
no suitable boundary exists.

Comment on lines +19 to +26
// 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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate files =="
git ls-files | rg '(^|/)textWrap\.ts$|(^|/)unicode\.ts$' || true

echo "== textWrap outline/contents =="
while IFS= read -r f; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done < <(git ls-files | rg '(^|/)textWrap\.ts$')

echo "== unicode outline/contents =="
while IFS= read -r f; do
  echo "--- $f"
  wc -l "$f"
  cat -n "$f"
done < <(git ls-files | rg '(^|/)unicode\.ts$')

echo "== usages =="
rg -n "wrapTextWithWidth|stringWidth|Intl\.Segmenter|Segmenter" .

Repository: Karanjot786/TermUI

Length of output: 331


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== runtime behavior probe: ANSI clustering and stringWidth state =="
node - <<'JS'
function stringWidth(s) {
  let total = 0;
  let inEscape = false;
  let codepoints = Array.from(s);
  for (let c of codepoints) {
    if (c === '\x1B') {
      inEscape = true;
      continue;
    }
    if (inEscape) {
      if (c === '[') continue;
      if (/[0-9;]*[A-Za-z]/.test(c)) {
        inEscape = false;
        continue;
      }
    }
    total += codepointWidth(c);
  }
  return total;
}
function codepointWidth(c) {
  return /\p{Wide}/u.test(c) ? 2 : 1;
}

const esc = '\x1B[31m';
const graphemebroken = `red${esc}text`;
const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'});
const clusters = Array.from(segmenter.segment(graphemebroken), s => s.segment);
console.log(JSON.stringify({ clusters, fullEscStripWidth: graphemebroken.length - esc.length, perClusterWidth: clusters.map(c => ({ c: c.replace(/\x1B/g,'<ESC>'), width: stringWidth(c) })) }));
JS

Repository: Karanjot786/TermUI

Length of output: 861


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file sizes =="
wc -l packages/core/src/utils/textWrap.ts packages/core/src/utils/unicode.ts

echo "== textWrap.ts =="
cat -n packages/core/src/utils/textWrap.ts

echo "== unicode.ts =="
cat -n packages/core/src/utils/unicode.ts

echo "== usages =="
rg -n "wrapTextWithWidth|stringWidth|Intl\.Segmenter|Segmenter" packages || true

echo "== runtime behavior probe (fixed regex and fd-style loop) =="
node - <<'JS'
function stringWidth(s, includeEscaped) {
  includeEscaped = !!includeEscaped;
  let total = 0;
  let inEscape = false;
  for (const c of [...s]) {
    if (c === '\x1B') {
      inEscape = true;
      if (includeEscaped) total += 1;
      continue;
    }
    if (inEscape) {
      if (c === '[') continue;
      if (/^[0-9;]*[A-Za-z]$/.test(c)) {
        inEscape = false;
        continue;
      }
    }
    // No grapheme-cluster expansion here: Node supports \p{GraphemeExtend} only.
    total += 1;
  }
  return total;
}

const esc = '\x1B[31m';
const graphemebroken = `red${esc}text`;
const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'});
const segments = Array.from(segmenter.segment(graphemebroken), s => s.segment);
const tokenWidth = segments.map((c, idx) => ({
  idx,
  c: c === '\x1B' ? '<ESC>' : c,
  width: stringWidth(c),
  inEscape: idx < 3 || (idx === 4 && c === 't')
}));
console.log(JSON.stringify({
  segmentsLength: segments.length,
  sequenceStartsAt: segments.findIndex(s => s === '\x1B'),
  wholeEscWidth: stringWidth(esc),
  segmentsAfterTokenization: segments.map((c, idx) => ({
    char: (c === '\x1B' ? '<ESC>' : c) + (idx === 3 && c === ']' ? '' : ''),
    width: stringWidth(c)
  })),
  totalWidthAsSeparated: segments.reduce((sum, c) => sum + stringWidth(c), 0),
  tokenWidth
}));
JS

Repository: Karanjot786/TermUI

Length of output: 50374


Keep ANSI escape sequences atomic during text wrapping.

Grapheme segmentation splits escape sequences such as \x1B[31m into separate characters. Each character is then passed to the single-character measurement path, so the trailing part can be counted as visible text and appended as ordinary characters. This miscounts wrap length and can break the preserved ANSI code in output.

Measure escape sequences as zero-width atomic tokens and avoid splitting them across wrapped lines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/src/utils/textWrap.ts` around lines 19 - 26, Update the
grapheme-cluster construction in the text-wrapping utility to recognize ANSI
escape sequences as zero-width atomic tokens before or during segmentation.
Preserve each complete sequence as one token, exclude it from visible-width
measurement, and ensure wrapping never splits a sequence across lines while
retaining it in the output.

Comment on lines +27 to +30
it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test title promises negative-width coverage; only zero is asserted.

The test name states "should handle zero or negative maxWidth gracefully," but line 28 only exercises maxWidth = 0. There is no assertion for a negative maxWidth (for example -5). Add that case so the test matches its stated intent.

✅ Suggested addition
   it('should handle zero or negative maxWidth gracefully', () => {
     expect(wrapTextWithWidth('Test', 0)).toEqual([]);
     expect(wrapTextWithWidth('', 10)).toEqual([]);
+    expect(wrapTextWithWidth('Test', -5)).toEqual([]);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
});
it('should handle zero or negative maxWidth gracefully', () => {
expect(wrapTextWithWidth('Test', 0)).toEqual([]);
expect(wrapTextWithWidth('', 10)).toEqual([]);
expect(wrapTextWithWidth('Test', -5)).toEqual([]);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/core/test/textWrap.test.ts` around lines 27 - 30, Update the test
“should handle zero or negative maxWidth gracefully” to add an assertion calling
wrapTextWithWidth with a negative maxWidth such as -5 and expecting an empty
array, while preserving the existing zero-width and empty-input assertions.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core @termuijs/core type:bug +10 pts. Bug fix. type:testing +10 pts. Tests.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Fix Text Wrapping & Multi-Byte Character Boundary Calculation on Window Resize

2 participants