Skip to content

Commit dcf8342

Browse files
Merge pull request #192 from RtlZeroMemory/feat/measure-element-truncate-start
feat(core): add measureElement and textOverflow start
2 parents 3baf62f + 8c0608d commit dcf8342

File tree

14 files changed

+237
-8
lines changed

14 files changed

+237
-8
lines changed

CLAUDE.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Current layout behavior to preserve when editing or generating docs:
107107
- Intrinsic sizing protocol (`measureMinContent` / `measureMaxContent`) is active for leaf and container measurement.
108108
- Stack constraints support `flex`, `flexShrink`, `flexBasis`, and per-child `alignSelf`.
109109
- Stack wrap and non-wrap paths use bounded cross-axis feedback (max 2 measure passes per child when needed).
110-
- Text supports `wrap` with grapheme-safe hard breaks and newline-aware paragraph splits.
110+
- Text supports `wrap` with grapheme-safe hard breaks and newline-aware paragraph splits. `textOverflow` supports `"clip"`, `"ellipsis"`, `"middle"`, and `"start"` (keeps tail with leading ellipsis).
111111
- Box has `gap` on its synthetic inner column and runs absolute children in a second out-of-flow pass.
112112
- Stack/box absolute positioning supports `position: "absolute"` + `top/right/bottom/left`.
113113
- Grid supports explicit placement and spans (`gridColumn`, `gridRow`, `colSpan`, `rowSpan`) with occupancy-aware auto placement.
@@ -308,6 +308,10 @@ each(items, (item) => ui.text(item.name), { key: (item) => item.id });
308308

309309
This allows centralized logging/middleware via app-level event listeners in addition to per-widget callbacks.
310310

311+
## Layout Measurement
312+
313+
`app.measureElement(id)` returns the computed layout `Rect` (`{ x, y, w, h }`) for any widget by its string `id`, or `null` if the widget is not in the current tree. The rect reflects the most recent layout pass. Types `Rect`, `Size`, and `Axis` are exported from `@rezi-ui/core`.
314+
311315
## Drawlist Codegen Protocol (MUST for ZRDL command changes)
312316

313317
When changing drawlist command layout/opcodes/field offsets for v3/v4/v5:

docs/migration/ink-to-rezi.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,17 +100,27 @@ ui.table({
100100

101101
### 4) Text styling and truncation
102102

103-
`ui.text` handles style + deterministic cell-aware truncation.
103+
`ui.text` handles style + deterministic cell-aware truncation. Four overflow modes are available: `"clip"` (default), `"ellipsis"`, `"middle"`, and `"start"`.
104104

105105
```typescript
106106
import { rgb } from "@rezi-ui/core";
107107

108108
ui.column({ gap: 1 }, [
109109
ui.text("Build failed", { style: { fg: rgb(255, 110, 110), bold: true } }),
110110
ui.text(state.path, { textOverflow: "middle", maxWidth: 40 }),
111+
ui.text(state.longPath, { textOverflow: "start" }), // keeps tail, e.g. "…src/index.ts"
111112
]);
112113
```
113114

115+
### 4b) Layout measurement
116+
117+
Ink's `measureElement` ref has a direct equivalent: `app.measureElement(id)` returns the computed `Rect` (`{ x, y, w, h }`) for any widget by its `id`, or `null` if not found.
118+
119+
```typescript
120+
const rect = app.measureElement("sidebar");
121+
if (rect) console.log(`sidebar is ${rect.w}x${rect.h} at (${rect.x},${rect.y})`);
122+
```
123+
114124
### 5) Forms
115125

116126
Use controlled `input` widgets and validate in update handlers, not during render.

docs/widgets/text.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ ui.text("Caption", { variant: "caption", textOverflow: "ellipsis" }); // pass Te
2222
| `key` | `string` | - | Reconciliation key for lists |
2323
| `style` | `TextStyle` | - | Style applied to this text |
2424
| `variant` | `"body" \| "heading" \| "caption" \| "code" \| "label"` | `"body"` | Predefined styling intent |
25-
| `textOverflow` | `"clip" \| "ellipsis" \| "middle"` | `"clip"` | How to handle overflow |
25+
| `textOverflow` | `"clip" \| "ellipsis" \| "middle" \| "start"` | `"clip"` | How to handle overflow |
2626
| `maxWidth` | `number` | - | Maximum width (cells) for overflow handling |
2727
| `wrap` | `boolean` | `false` | Wrap text into multiple lines using cell-width-aware line breaking |
2828

@@ -59,7 +59,20 @@ ui.box({ width: 24, border: "single", p: 1 }, [
5959
]);
6060
```
6161

62-
### 4) Wrapped multiline text
62+
### 4) Start truncation
63+
64+
Keeps the tail of the string, prepending an ellipsis. Useful for file paths where the significant part is at the end.
65+
66+
```typescript
67+
import { ui } from "@rezi-ui/core";
68+
69+
ui.box({ width: 30, border: "single", p: 1 }, [
70+
ui.text("/home/user/documents/project/src/index.ts", { textOverflow: "start" }),
71+
]);
72+
// renders: "…uments/project/src/index.ts"
73+
```
74+
75+
### 5) Wrapped multiline text
6376

6477
```typescript
6578
import { ui } from "@rezi-ui/core";

packages/core/src/app/createApp.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2040,6 +2040,11 @@ export function createApp<S>(opts: CreateAppStateOptions<S> | CreateAppRoutesOnl
20402040
return terminalProfile;
20412041
},
20422042

2043+
measureElement(id: string): Rect | null {
2044+
if (mode !== "widget") return null;
2045+
return widgetRenderer.getRectByIdIndex().get(id) ?? null;
2046+
},
2047+
20432048
...(routerIntegration ? { router: routerIntegration.router } : {}),
20442049
};
20452050

packages/core/src/app/inspectorOverlayHelper.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,9 @@ export function createAppWithInspectorOverlay<S>(
300300
getTerminalProfile() {
301301
return app.getTerminalProfile();
302302
},
303+
measureElement(id: string) {
304+
return app.measureElement(id);
305+
},
303306
...(app.router ? { router: app.router } : {}),
304307
inspectorOverlay: controller,
305308
};

packages/core/src/app/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,5 +62,7 @@ export interface App<S> {
6262
getBindings(mode?: string): readonly RegisteredBinding[];
6363
readonly pendingChord: string | null;
6464
getTerminalProfile(): TerminalProfile;
65+
/** Return the computed layout rect for a widget by its `id`, or `null` if not found. */
66+
measureElement(id: string): Rect | null;
6567
readonly router?: RouterApi;
6668
}

packages/core/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export {
6565
measureTextCells,
6666
truncateWithEllipsis,
6767
truncateMiddle,
68+
truncateStart,
6869
clearTextMeasureCache,
6970
getTextMeasureCacheSize,
7071
setTextMeasureEmojiPolicy,
@@ -503,6 +504,12 @@ export {
503504
} from "./widgets/virtualList.js";
504505
export { rgb, type Rgb, type TextStyle } from "./widgets/style.js";
505506

507+
// =============================================================================
508+
// Layout Types
509+
// =============================================================================
510+
511+
export type { Rect, Size, Axis } from "./layout/types.js";
512+
506513
// =============================================================================
507514
// Spacing Scale
508515
// =============================================================================

packages/core/src/layout/__tests__/textMeasure.truncate.unicode.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { assert, describe, test } from "@rezi-ui/testkit";
2-
import { measureTextCells, truncateMiddle, truncateWithEllipsis } from "../textMeasure.js";
2+
import {
3+
measureTextCells,
4+
truncateMiddle,
5+
truncateStart,
6+
truncateWithEllipsis,
7+
} from "../textMeasure.js";
38

49
function hasUnpairedSurrogate(text: string): boolean {
510
for (let i = 0; i < text.length; i++) {
@@ -51,4 +56,62 @@ describe("unicode-safe truncation", () => {
5156
assert.equal(hasUnpairedSurrogate(result), false);
5257
assert.ok(measureTextCells(result) <= 4);
5358
});
59+
60+
test("truncateStart does not split surrogate pairs", () => {
61+
const result = truncateStart("A😀B", 3);
62+
assert.equal(result, "…B");
63+
assert.equal(hasUnpairedSurrogate(result), false);
64+
assert.ok(measureTextCells(result) <= 3);
65+
});
66+
67+
test("truncateStart does not split ZWJ emoji clusters", () => {
68+
const family = "👨‍👩‍👧‍👦";
69+
const result = truncateStart(`Z${family}`, 2);
70+
assert.equal(hasUnpairedSurrogate(result), false);
71+
assert.ok(measureTextCells(result) <= 2);
72+
});
73+
});
74+
75+
describe("truncateStart", () => {
76+
test("returns full text when it fits", () => {
77+
assert.equal(truncateStart("abcd", 4), "abcd");
78+
assert.equal(truncateStart("abcd", 10), "abcd");
79+
});
80+
81+
test("empty string returns empty", () => {
82+
assert.equal(truncateStart("", 5), "");
83+
});
84+
85+
test("width=0 returns empty", () => {
86+
assert.equal(truncateStart("abcdef", 0), "");
87+
});
88+
89+
test("width=1 returns only ellipsis", () => {
90+
assert.equal(truncateStart("abcdef", 1), "…");
91+
});
92+
93+
test("width=2 keeps one trailing char", () => {
94+
assert.equal(truncateStart("abcdef", 2), "…f");
95+
});
96+
97+
test("width=3 keeps two trailing chars", () => {
98+
assert.equal(truncateStart("abcdef", 3), "…ef");
99+
});
100+
101+
test("width=5 keeps four trailing chars", () => {
102+
assert.equal(truncateStart("abcdef", 5), "…cdef");
103+
});
104+
105+
test("CJK wide chars counted as 2 cells", () => {
106+
// "你好世界" = 4 chars, 8 cells
107+
const result = truncateStart("你好世界", 5);
108+
assert.equal(result, "…世界");
109+
assert.ok(measureTextCells(result) <= 5);
110+
});
111+
112+
test("mixed ASCII and CJK", () => {
113+
const result = truncateStart("ab你好cd", 5);
114+
assert.equal(result, "…好cd");
115+
assert.ok(measureTextCells(result) <= 5);
116+
});
54117
});

packages/core/src/layout/textMeasure.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,39 @@ export function truncateWithEllipsis(text: string, maxWidth: number): string {
618618
* // "/home/user/…/src/index.ts"
619619
* ```
620620
*/
621+
/**
622+
* Truncate text from the start, preserving the end.
623+
* Useful for file paths and URLs where the tail is most important.
624+
*
625+
* @param text - The text to truncate
626+
* @param maxWidth - Maximum width in terminal cells
627+
* @returns Truncated text with ellipsis at start, or original if it fits
628+
*
629+
* @example
630+
* ```typescript
631+
* truncateStart("/home/user/documents/project/src/index.ts", 20)
632+
* // "…ject/src/index.ts"
633+
* ```
634+
*/
635+
export function truncateStart(text: string, maxWidth: number): string {
636+
const fullWidth = measureTextCells(text);
637+
if (fullWidth <= maxWidth) return text;
638+
if (maxWidth <= 0) return "";
639+
if (maxWidth === 1) return "…";
640+
641+
// Reserve 1 cell for ellipsis
642+
const targetWidth = maxWidth - 1;
643+
if (targetWidth <= 0) return "…";
644+
645+
const { starts, prefixWidths } = collectGraphemeSlices(text);
646+
const clusterCount = starts.length;
647+
const endClusters = maxSuffixClustersWithinWidth(prefixWidths, targetWidth);
648+
if (endClusters === 0) return "…";
649+
const endStartCluster = clusterCount - endClusters;
650+
const endStart = starts[endStartCluster] ?? text.length;
651+
return `…${text.slice(endStart)}`;
652+
}
653+
621654
export function truncateMiddle(text: string, maxWidth: number): string {
622655
const fullWidth = measureTextCells(text);
623656
if (fullWidth <= maxWidth) return text;

packages/core/src/renderer/__tests__/renderer.text.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,58 @@ describe("renderer text - truncation boundaries", () => {
620620
assert.equal(expectSingleDrawText(frame).text, "ab…");
621621
});
622622

623+
test("start truncation width=5 keeps tail", () => {
624+
const frame = parseFrame(
625+
renderBytes(textVNode("abcdef", { textOverflow: "start", maxWidth: 5 }), {
626+
cols: 20,
627+
rows: 1,
628+
}),
629+
);
630+
assert.equal(expectSingleDrawText(frame).text, "…cdef");
631+
});
632+
633+
test("start truncation width=3 keeps two trailing chars", () => {
634+
const frame = parseFrame(
635+
renderBytes(textVNode("abcdef", { textOverflow: "start", maxWidth: 3 }), {
636+
cols: 20,
637+
rows: 1,
638+
}),
639+
);
640+
assert.equal(expectSingleDrawText(frame).text, "…ef");
641+
});
642+
643+
test("start truncation width=1 is only ellipsis", () => {
644+
const frame = parseFrame(
645+
renderBytes(textVNode("abcdef", { textOverflow: "start", maxWidth: 1 }), {
646+
cols: 20,
647+
rows: 1,
648+
}),
649+
);
650+
assert.equal(expectSingleDrawText(frame).text, "…");
651+
});
652+
653+
test("start truncation width=0 produces no text command", () => {
654+
const frame = parseFrame(
655+
renderBytes(textVNode("abcdef", { textOverflow: "start", maxWidth: 0 }), {
656+
cols: 20,
657+
rows: 1,
658+
}),
659+
);
660+
assert.equal(frame.drawTexts.length, 0);
661+
assert.equal(frame.drawTextRuns.length, 0);
662+
assert.equal(frame.strings.length, 0);
663+
});
664+
665+
test("start truncation exact boundary keeps full text", () => {
666+
const frame = parseFrame(
667+
renderBytes(textVNode("abcd", { textOverflow: "start", maxWidth: 4 }), {
668+
cols: 20,
669+
rows: 1,
670+
}),
671+
);
672+
assert.equal(expectSingleDrawText(frame).text, "abcd");
673+
});
674+
623675
test("text longer than viewport width uses clip and keeps full source string", () => {
624676
const source = "0123456789";
625677
const frame = parseFrame(renderBytes(textVNode(source), { cols: 5, rows: 1 }));

0 commit comments

Comments
 (0)