Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-widths-fit.md
Original file line number Diff line number Diff line change
@@ -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)).
166 changes: 60 additions & 106 deletions packages/effect-boxes/src/internal/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++) {
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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<
Expand Down
85 changes: 31 additions & 54 deletions packages/effect-boxes/src/internal/box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Hash,
Inspectable,
Match,
Option,
pipe,
String,
} from "effect";
Expand Down Expand Up @@ -206,7 +207,11 @@ export const emptyBox = (rows = 0, cols = 0): Box.Box<never> =>
/** @internal */

export const char = (c: string): Box.Box<never> => {
const grapheme = Width.segments(c)[0] ?? " ";
const grapheme = pipe(
Width.segments(c),
Array.head,
Option.getOrElse(() => " ")
);
return make({
rows: 1,
cols: Width.ofString(grapheme),
Expand Down Expand Up @@ -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))
);
});

Expand Down Expand Up @@ -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;
};

/*
Expand Down Expand Up @@ -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)
)
);
Expand All @@ -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
Expand Down Expand Up @@ -1248,29 +1253,6 @@ const truncateWidth = <A>(
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 = <A>(
self: Box.Box<A>,
that: Box.Box<A>
Expand Down Expand Up @@ -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<A>[] = [];
Expand Down Expand Up @@ -1464,7 +1446,6 @@ export const truncate = dual<
const ellipsis = "…";

const truncateLine = (text: string): Box.Box<A> => {
const segs = Width.segments(text);
const textWidth = Width.ofString(text);

if (textWidth <= width) {
Expand All @@ -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);
Expand All @@ -1522,9 +1501,7 @@ export const maxWidth = dual<
>(
2,
<A>(self: Box.Box<A>, n: number): Box.Box<A> =>
truncateWidth(self, n, (t) =>
unsafeLine(Width.segments(t).slice(0, n).join(""))
)
truncateWidth(self, n, (t) => unsafeLine(Width.sliceColumns(t, 0, n)))
);

/** @internal */
Expand Down
Loading
Loading