diff --git a/.changeset/calm-widths-fit.md b/.changeset/calm-widths-fit.md new file mode 100644 index 0000000..c4112bf --- /dev/null +++ b/.changeset/calm-widths-fit.md @@ -0,0 +1,5 @@ +--- +"effect-boxes": patch +--- + +Box rendering and width constraints now keep CJK text, emoji, and combining graphemes within their declared terminal columns ([#96](https://github.com/lloydrichards/effect-boxes/issues/96)). diff --git a/packages/effect-boxes/src/internal/ansi.ts b/packages/effect-boxes/src/internal/ansi.ts index 24246b3..84da219 100644 --- a/packages/effect-boxes/src/internal/ansi.ts +++ b/packages/effect-boxes/src/internal/ansi.ts @@ -319,88 +319,18 @@ const findAnsiSequenceEnd = ( return chars.length; }; -/** @internal */ -export const truncatePreservingAnsi = ( - str: string, - maxVisibleLength: number -): string => { - if (Width.ofString(str) <= maxVisibleLength) { - return str; - } - - const segments = Width.segments(str); - - // Optimized imperative loop for better performance in hot path - let result = ""; - let visibleCount = 0; - let skipNext = 0; - - for (let index = 0; index < segments.length; index++) { - if (skipNext > 0) { - skipNext--; - continue; - } - if (visibleCount >= maxVisibleLength) { - break; - } - - const cur = segments[index]; - - if (cur === ESC && segments[index + 1] === "[") { - const sequenceEnd = findAnsiSequenceEnd(segments, index); - // Batch append ANSI sequence for efficiency - const sequenceParts: string[] = []; - for (let i = index; i < sequenceEnd; i++) { - sequenceParts.push(segments[i] || ""); - } - result += sequenceParts.join(""); - skipNext = sequenceEnd - index - 1; - } else { - result += cur; - visibleCount++; - } - } - - // Ensure ANSI sequences are properly terminated to prevent color bleed - if (result.includes(ESC) && !result.endsWith(RESET)) { - return result + RESET; - } - - return result; -}; - -const truncateAlignedPreservingAnsi = ( - str: string, - maxVisibleLength: number, - alignment: Box.Alignment +const sliceAnsiColumns = ( + input: string, + offset: number, + width: number ): string => { - if (maxVisibleLength <= 0) { - return ""; - } - if (!str.includes(ESC)) { - return takePA(Width.segments(str), alignment, " ", maxVisibleLength).join( - "" - ); - } - - const visibleLength = Width.ofString(str); - const overflow = visibleLength - maxVisibleLength; - const start = (() => { - switch (alignment) { - case "AlignFirst": - return 0; - case "AlignLast": - return overflow; - case "AlignCenter1": - return Math.ceil(overflow / 2); - case "AlignCenter2": - return Math.floor(overflow / 2); - } - })(); - const end = start + maxVisibleLength; - const segments = Width.segments(str); + const start = Math.max(0, offset); + const targetWidth = Math.max(0, width); + const end = start + targetWidth; + const segments = Width.segments(input); let result = ""; let column = 0; + let hasVisibleContent = false; let skipNext = 0; for (let index = 0; index < segments.length; index++) { @@ -421,6 +351,13 @@ const truncateAlignedPreservingAnsi = ( const nextColumn = column + segmentWidth; if (column >= start && nextColumn <= end) { result += segment; + hasVisibleContent ||= segmentWidth > 0; + } else { + const overlap = Math.max( + 0, + Math.min(nextColumn, end) - Math.max(column, start) + ); + result += " ".repeat(overlap); } column = nextColumn; @@ -429,10 +366,47 @@ const truncateAlignedPreservingAnsi = ( } } + if (!hasVisibleContent) { + return " ".repeat(targetWidth); + } + return result.includes(ESC) && !result.endsWith(RESET) ? result + RESET : result; }; + +/** @internal */ +export const truncatePreservingAnsi = ( + str: string, + maxVisibleLength: number +): string => { + if (Width.ofString(str) <= maxVisibleLength) { + return str; + } + + return sliceAnsiColumns(str, 0, maxVisibleLength); +}; + +const truncateAlignedPreservingAnsi = ( + str: string, + maxVisibleLength: number, + alignment: Box.Alignment +): string => { + if (maxVisibleLength <= 0) { + return ""; + } + if (!str.includes(ESC)) { + return Width.fitString(str, maxVisibleLength, alignment); + } + + const visibleLength = Width.ofString(str); + const start = -Width.alignmentOffset( + alignment, + visibleLength, + maxVisibleLength + ); + return sliceAnsiColumns(str, start, maxVisibleLength); +}; /** @internal */ export const padPreservingAnsi = ( str: string, @@ -447,34 +421,14 @@ export const padPreservingAnsi = ( return truncateAlignedPreservingAnsi(str, targetVisibleLength, alignment); } - // Fast path for simple cases without ANSI sequences - if (!str.includes(ESC)) { - const padding = " ".repeat(targetVisibleLength - currentVisibleLength); - switch (alignment) { - case "AlignFirst": - return str + padding; - case "AlignLast": - return padding + str; - case "AlignCenter1": - case "AlignCenter2": { - const leftPad = Math.floor( - (targetVisibleLength - currentVisibleLength) / 2 - ); - const rightPad = targetVisibleLength - currentVisibleLength - leftPad; - return " ".repeat(leftPad) + str + " ".repeat(rightPad); - } - } - } - - // Use grapheme segmentation for proper emoji handling (complex case with ANSI) - const segments = Width.segments(str); - - return takePA( - segments, + const padding = targetVisibleLength - currentVisibleLength; + const leftPadding = Width.alignmentOffset( alignment, - " ", - segments.length + targetVisibleLength - currentVisibleLength - ).join(""); + currentVisibleLength, + targetVisibleLength + ); + + return " ".repeat(leftPadding) + str + " ".repeat(padding - leftPadding); }; const resizeBox = dual< diff --git a/packages/effect-boxes/src/internal/box.ts b/packages/effect-boxes/src/internal/box.ts index b509c5e..bfa0292 100644 --- a/packages/effect-boxes/src/internal/box.ts +++ b/packages/effect-boxes/src/internal/box.ts @@ -5,6 +5,7 @@ import { Hash, Inspectable, Match, + Option, pipe, String, } from "effect"; @@ -206,7 +207,11 @@ export const emptyBox = (rows = 0, cols = 0): Box.Box => /** @internal */ export const char = (c: string): Box.Box => { - const grapheme = Width.segments(c)[0] ?? " "; + const grapheme = pipe( + Width.segments(c), + Array.head, + Option.getOrElse(() => " ") + ); return make({ rows: 1, cols: Width.ofString(grapheme), @@ -524,7 +529,7 @@ const flow = dual< Array.filter((word) => word.length > 0), Array.reduce(emptyPara(width), addWordP), getLines, - Array.map((line) => line.slice(0, width)) + Array.map((line) => Width.sliceColumns(line, 0, width)) ); }); @@ -563,13 +568,13 @@ const wordFits = ( word: string ): boolean => { if (paraContent.lastLine.length === 0) { - return word.length <= paraWidth; + return Width.ofString(word) <= paraWidth; } const currentLength = paraContent.lastLine.reduce( - (acc: number, w: string) => acc + w.length, + (acc: number, word: string) => acc + Width.ofString(word), paraContent.lastLine.length - 1 ); - return currentLength + 1 + word.length <= paraWidth; + return currentLength + 1 + Width.ofString(word) <= paraWidth; }; /* @@ -763,7 +768,7 @@ export const resizeBox = dual< (self: string[], r: number, c: number) => string[] >(3, (self, r, c) => pipe( - self.map((line) => takeP(Width.segments(line), " ", c).join("")), + self.map((line) => Width.fitString(line, c, left)), takeP(blanks(c), r) ) ); @@ -773,7 +778,7 @@ export const resizeBoxAligned = (r: number, c: number, ha: Box.Alignment, va: Box.Alignment) => (self: string[]) => takePA( - self.map((line) => takePA(Width.segments(line), ha, " ", c).join("")), + self.map((line) => Width.fitString(line, c, ha)), va, blanks(c), r @@ -1248,29 +1253,6 @@ const truncateWidth = ( return result; }; -const cropLine = (text: string, offset: number, width: number): string => { - const start = Math.max(0, offset); - const end = start + Math.max(0, width); - let column = 0; - const result: string[] = []; - - for (const segment of Width.segments(text)) { - const segmentWidth = Width.ofString(segment); - const nextColumn = column + segmentWidth; - - if (column >= start && nextColumn <= end) { - result.push(segment); - } - - column = nextColumn; - if (column >= end) { - break; - } - } - - return result.join(""); -}; - const preserveAnnotation = ( self: Box.Box, that: Box.Box @@ -1314,7 +1296,7 @@ export const cropWidth = dual< text: (text) => preserveAnnotation( box, - unsafeLine(cropLine(text, columnOffset, columnsToKeep)) + unsafeLine(Width.sliceColumns(text, columnOffset, columnsToKeep)) ), row: (boxes) => { const cropped: Box.Box[] = []; @@ -1464,7 +1446,6 @@ export const truncate = dual< const ellipsis = "…"; const truncateLine = (text: string): Box.Box => { - const segs = Width.segments(text); const textWidth = Width.ofString(text); if (textWidth <= width) { @@ -1477,29 +1458,27 @@ export const truncate = dual< const available = width - 1; // reserve 1 column for ellipsis - return Match.value(pos).pipe( - Match.when("AlignFirst", () => - unsafeLine(segs.slice(0, available).join("") + ellipsis) + const [prefixWidth, suffixWidth] = Match.value(pos).pipe( + Match.when("AlignFirst", () => [available, 0] as const), + Match.when("AlignLast", () => [0, available] as const), + Match.when( + "AlignCenter1", + () => + [Math.ceil(available / 2), Math.floor(available / 2)] as const ), - Match.when("AlignLast", () => - unsafeLine(ellipsis + segs.slice(segs.length - available).join("")) - ), - Match.when("AlignCenter1", () => - unsafeLine( - segs.slice(0, Math.ceil(available / 2)).join("") + - ellipsis + - segs.slice(segs.length - Math.floor(available / 2)).join("") - ) - ), - Match.when("AlignCenter2", () => - unsafeLine( - segs.slice(0, Math.floor(available / 2)).join("") + - ellipsis + - segs.slice(segs.length - Math.ceil(available / 2)).join("") - ) + Match.when( + "AlignCenter2", + () => + [Math.floor(available / 2), Math.ceil(available / 2)] as const ), Match.exhaustive ); + + return unsafeLine( + Width.sliceColumns(text, 0, prefixWidth) + + ellipsis + + Width.sliceColumns(text, textWidth - suffixWidth, suffixWidth) + ); }; return truncateWidth(self, width, truncateLine); @@ -1522,9 +1501,7 @@ export const maxWidth = dual< >( 2, (self: Box.Box, n: number): Box.Box => - truncateWidth(self, n, (t) => - unsafeLine(Width.segments(t).slice(0, n).join("")) - ) + truncateWidth(self, n, (t) => unsafeLine(Width.sliceColumns(t, 0, n))) ); /** @internal */ diff --git a/packages/effect-boxes/src/internal/width.ts b/packages/effect-boxes/src/internal/width.ts index 00978d8..6284585 100644 --- a/packages/effect-boxes/src/internal/width.ts +++ b/packages/effect-boxes/src/internal/width.ts @@ -1,4 +1,5 @@ -import { Array, pipe } from "effect"; +import { Array, Match, pipe } from "effect"; +import type * as Box from "../Box.js"; const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)"; // Valid string terminator sequences are BEL, ESC\, and 0x9c const osc = `(?:\\u001B\\][\\s\\S]*?${ST})`; // ESC ] ... ST (non-greedy until first ST) @@ -198,3 +199,80 @@ export const ofString = (input: string): number => { return result; }; + +/** @internal */ +export const sliceColumns = ( + input: string, + offset: number, + width: number +): string => { + if (width <= 0) { + return ""; + } + + const start = Math.max(0, offset); + const end = start + width; + let column = 0; + const result: string[] = []; + + for (const segment of segments(input)) { + const segmentWidth = ofString(segment); + const nextColumn = column + segmentWidth; + + if (column >= start && nextColumn <= end) { + result.push(segment); + } else { + const overlap = Math.max( + 0, + Math.min(nextColumn, end) - Math.max(column, start) + ); + if (overlap > 0) { + result.push(" ".repeat(overlap)); + } + } + + column = nextColumn; + if (column >= end) { + break; + } + } + + return result.join(""); +}; + +const alignmentAnchor = (alignment: Box.Alignment, width: number): number => + Match.value(alignment).pipe( + Match.when("AlignFirst", () => 0), + Match.when("AlignLast", () => width), + Match.when("AlignCenter1", () => Math.ceil(width / 2)), + Match.when("AlignCenter2", () => Math.floor(width / 2)), + Match.exhaustive + ); + +/** @internal */ +export const alignmentOffset = ( + alignment: Box.Alignment, + inputWidth: number, + targetWidth: number +): number => + alignmentAnchor(alignment, targetWidth) - + alignmentAnchor(alignment, inputWidth); + +/** @internal */ +export const fitString = ( + input: string, + width: number, + alignment: Box.Alignment +): string => { + const targetWidth = Math.max(0, width); + const inputWidth = ofString(input); + + if (inputWidth > targetWidth) { + const offset = -alignmentOffset(alignment, inputWidth, targetWidth); + return sliceColumns(input, offset, targetWidth); + } + + const padding = targetWidth - inputWidth; + const before = alignmentOffset(alignment, inputWidth, targetWidth); + return " ".repeat(before) + input + " ".repeat(padding - before); +}; diff --git a/packages/effect-boxes/src/renderer/PlainRenderer.ts b/packages/effect-boxes/src/renderer/PlainRenderer.ts index 610eb48..9b1d048 100644 --- a/packages/effect-boxes/src/renderer/PlainRenderer.ts +++ b/packages/effect-boxes/src/renderer/PlainRenderer.ts @@ -1,7 +1,6 @@ import { Effect, Layer } from "effect"; import type * as Annotation from "../Annotation.js"; import type * as Box from "../Box.js"; -import { takeP, takePA } from "../internal/box.js"; import { Renderer, renderBox } from "../internal/renderer.js"; import * as Width from "../internal/width.js"; import type * as R from "../Renderer.js"; @@ -13,12 +12,12 @@ export const makePlainRenderer = Layer.effect( Effect.gen(function* () { const processor: R.TextProcessor = { processLine: (text: string, targetWidth: number) => - takeP(Width.segments(text), " ", targetWidth).join(""), + Width.fitString(text, targetWidth, "AlignFirst"), processLineAligned: ( text: string, targetWidth: number, alignment: Box.Alignment - ) => takePA(Width.segments(text), alignment, " ", targetWidth).join(""), + ) => Width.fitString(text, targetWidth, alignment), preservesFormatting: false, }; diff --git a/packages/effect-boxes/tests/ansi-render-config.test.ts b/packages/effect-boxes/tests/ansi-render-config.test.ts index 9c906a8..af91f6b 100644 --- a/packages/effect-boxes/tests/ansi-render-config.test.ts +++ b/packages/effect-boxes/tests/ansi-render-config.test.ts @@ -31,6 +31,16 @@ describe("Ansi Render Configuration", () => { expect(Box.renderPrettySync(box)).toBe("\x1b[31mbc\x1b[0m"); }); + + it("should preserve each center bias when content needs odd padding", () => { + const center1 = Box.alignHoriz(Box.text("abcd"), Box.center1, 9); + const center2 = Box.alignHoriz(Box.text("abcd"), Box.center2, 9); + + expect(Box.renderPlainSync(center1)).toBe(" abcd "); + expect(Box.renderPrettySync(center1)).toBe(" abcd"); + expect(Box.renderPlainSync(center2)).toBe(" abcd "); + expect(Box.renderPrettySync(center2)).toBe(" abcd"); + }); }); // ============================================================================ diff --git a/packages/effect-boxes/tests/box.test.ts b/packages/effect-boxes/tests/box.test.ts index f096ff3..5442792 100644 --- a/packages/effect-boxes/tests/box.test.ts +++ b/packages/effect-boxes/tests/box.test.ts @@ -430,11 +430,11 @@ describe("para", () => { expect(Box.renderPrettySync(box)).toBe( String.stripMargin( `|This is a - | test + | test |paragraph - | that - | should - | flow + | that + | should + | flow | nicely.` ) ); @@ -1192,7 +1192,7 @@ describe("Annotation Functions", () => { expect(Box.renderPlainSync(emojiBox).replaceAll(" ", ".")).toBe( String.stripMargin( `|... - |.😁. + |.😁 |...` ) ); @@ -1209,7 +1209,7 @@ describe("Annotation Functions", () => { expect(Box.renderPlainSync(emojiBox).replaceAll(" ", ".")).toBe( String.stripMargin( `|... - |.👩‍💻. + |.👩‍💻 |...` ) ); diff --git a/packages/effect-boxes/tests/unicode-width.test.ts b/packages/effect-boxes/tests/unicode-width.test.ts new file mode 100644 index 0000000..fb06cc6 --- /dev/null +++ b/packages/effect-boxes/tests/unicode-width.test.ts @@ -0,0 +1,150 @@ +import { Effect, pipe } from "effect"; +import { describe, expect, it } from "vitest"; +import * as Ansi from "../src/Ansi"; +import * as Box from "../src/Box"; +import * as Renderer from "../src/Renderer"; + +const renderAnsiPreservingWhitespace = (box: Box.Box): string => + Effect.runSync( + Renderer.render(box, { preserveWhitespace: true }).pipe( + Effect.provide(Renderer.AnsiRendererLive) + ) + ); + +describe("Unicode display widths", () => { + it("should keep declared columns equal to rendered columns for CJK text", () => { + const box = Box.text("你好"); + + expect(box.cols).toBe(4); + expect(Box.renderPlainSync(box)).toBe("你好"); + }); + + it("should keep a joined emoji intact at a width boundary", () => { + const box = Box.maxWidth(Box.line("👩‍💻x"), 2); + + expect(box.cols).toBe(2); + expect(Box.renderPlainSync(box)).toBe("👩‍💻"); + }); + + it("should preserve a decomposed combining grapheme at a width boundary", () => { + const decomposed = "e\u0301"; + const box = Box.maxWidth(Box.line(`${decomposed}x`), 1); + + expect(box.cols).toBe(1); + expect(Box.renderPlainSync(box)).toBe(decomposed); + }); + + it("should truncate wide text from the left within the column budget", () => { + const box = Box.truncate(Box.line("你AB好CD界"), 5, Box.left); + + expect(box.cols).toBe(5); + expect(Box.renderPlainSync(box)).toBe("你AB…"); + }); + + it("should truncate wide text from the right within the column budget", () => { + const box = Box.truncate(Box.line("你AB好CD界"), 5, Box.right); + + expect(box.cols).toBe(5); + expect(Box.renderPlainSync(box)).toBe("…CD界"); + }); + + it("should preserve the left bias of center1 when truncating wide text", () => { + const box = Box.truncate(Box.line("你AB好CD界"), 6, Box.center1); + + expect(box.cols).toBe(6); + expect(Box.renderPlainSync(box)).toBe("你A…界"); + }); + + it("should preserve the right bias of center2 when truncating wide text", () => { + const box = Box.truncate(Box.line("你AB好CD界"), 6, Box.center2); + + expect(box.cols).toBe(6); + expect(Box.renderPlainSync(box)).toBe("你…D界"); + }); + + it("should fit a wide grapheme and ellipsis within three columns", () => { + const box = Box.truncate(Box.line("你好世界"), 3, Box.left); + + expect(box.cols).toBe(3); + expect(Box.renderPrettySync(box)).toBe("你…"); + }); + + it("should fit max-width wide text within the requested columns", () => { + const box = Box.maxWidth(Box.line("你好"), 2); + + expect(box.cols).toBe(2); + expect(Box.renderPlainSync(box)).toBe("你"); + }); + + it("should omit a wide grapheme when maxWidth has only one column", () => { + const box = Box.maxWidth(Box.line("你好"), 1); + + expect(box.cols).toBe(1); + expect(Box.renderPlainSync(box)).toBe(" "); + }); + + it("should replace a partially cropped wide grapheme with a blank", () => { + const box = Box.cropWidth(Box.line("A你B"), 2, 1); + + expect(box.cols).toBe(1); + expect(Box.renderPlainSync(box)).toBe(" "); + }); + + it("should preserve center alignment bias at a wide boundary", () => { + const leftBiased = Box.alignHoriz(Box.text("你好"), Box.center1, 3); + const rightBiased = Box.alignHoriz(Box.text("你好"), Box.center2, 3); + + expect(Box.renderPlainSync(leftBiased)).toBe("你 "); + expect(Box.renderPlainSync(rightBiased)).toBe(" 好"); + }); + + it("should clip ANSI-styled wide text without losing style boundaries", () => { + const styled = "\u001b[31m你好\u001b[0m"; + + expect(Ansi.truncatePreservingAnsi(styled, 2)).toBe( + "\u001b[31m你\u001b[0m" + ); + }); + + it("should render one blank column when aligned ANSI text cannot fit", () => { + const box = pipe( + Box.text("你"), + Box.annotate(Ansi.red), + Box.alignHoriz(Box.left, 1) + ); + + expect(renderAnsiPreservingWhitespace(box)).toBe(" "); + }); + + it("should retain ANSI styling when aligned clipping keeps wide text", () => { + const box = pipe( + Box.text("A你"), + Box.annotate(Ansi.red), + Box.alignHoriz(Box.right, 2) + ); + expect(renderAnsiPreservingWhitespace(box)).toBe( + "\u001b[31m你\u001b[0m" + ); + }); + + it("should flow paragraphs by columns and retain long-word truncation", () => { + const wrapped = Box.para("你好 ab", Box.left, 5); + const longWord = Box.para("你好世界", Box.left, 3); + + expect(wrapped.cols).toBe(4); + expect(Box.renderPlainSync(wrapped)).toBe("你好\nab "); + expect(longWord.cols).toBe(3); + expect(Box.renderPlainSync(longWord)).toBe("你 "); + }); + + it("should resize wide text to exact terminal columns", () => { + expect(Box.resizeBox(["你好"], 1, 3)).toEqual(["你 "]); + expect(Box.resizeBox(["你"], 1, 1)).toEqual([" "]); + }); + + it("should align resized wide text by terminal columns", () => { + expect(Box.resizeBoxAligned(1, 4, Box.right, Box.top)(["你"])).toEqual([ + " 你", + ]); + }); +});