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
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ jobs:
if: runner.os == 'Linux'
run: npm run test:e2e

- name: Terminal e2e (Linux reduced)
if: runner.os == 'Linux'
run: npm run test:e2e:reduced

- name: Terminal e2e equivalent (non-Linux reduced)
if: runner.os != 'Linux'
run: npm run test:e2e:reduced
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { assert, describe, test } from "@rezi-ui/testkit";
import { measureTextCells, truncateMiddle, truncateWithEllipsis } from "../textMeasure.js";

function hasUnpairedSurrogate(text: string): boolean {
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
const isHigh = code >= 0xd800 && code <= 0xdbff;
const isLow = code >= 0xdc00 && code <= 0xdfff;
if (!isHigh && !isLow) continue;

if (isHigh) {
const next = text.charCodeAt(i + 1);
const nextIsLow = next >= 0xdc00 && next <= 0xdfff;
if (!nextIsLow) return true;
i++;
continue;
}

return true;
}
return false;
}

describe("unicode-safe truncation", () => {
test("truncateWithEllipsis does not split surrogate pairs", () => {
const result = truncateWithEllipsis("A😀B", 3);
assert.equal(result, "A…");
assert.equal(hasUnpairedSurrogate(result), false);
assert.ok(measureTextCells(result) <= 3);
});

test("truncateWithEllipsis does not split ZWJ emoji clusters", () => {
const family = "👨‍👩‍👧‍👦";
const result = truncateWithEllipsis(`${family}Z`, 2);
assert.equal(result, "…");
assert.equal(hasUnpairedSurrogate(result), false);
assert.ok(measureTextCells(result) <= 2);
});

test("truncateMiddle does not split surrogate pairs", () => {
const result = truncateMiddle("abc😀", 4);
assert.equal(result, "ab…");
assert.equal(hasUnpairedSurrogate(result), false);
assert.ok(measureTextCells(result) <= 4);
});

test("truncateMiddle does not split ZWJ emoji clusters", () => {
const family = "👨‍👩‍👧‍👦";
const result = truncateMiddle(`abc${family}`, 4);
assert.equal(result, "ab…");
assert.equal(hasUnpairedSurrogate(result), false);
assert.ok(measureTextCells(result) <= 4);
});
});
142 changes: 83 additions & 59 deletions packages/core/src/layout/textMeasure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ function measureTextCellsAsciiOnly(text: string): number | null {
return total;
}

function measureTextCellsUncached(text: string): number {
type GraphemeVisitor = (start: number, end: number, width: 0 | 1 | 2) => void;

function scanGraphemeClusters(text: string, onCluster?: GraphemeVisitor): number {
let total = 0;
let off = 0;

Expand Down Expand Up @@ -285,6 +287,7 @@ function measureTextCellsUncached(text: string): number {

if (hasEmoji && width < 2) width = 2;
total += width;
onCluster?.(start, off, width);

// Defensive progress guard: if decode returned 0-sized, force progress deterministically.
if (off === start) off++;
Expand All @@ -293,6 +296,73 @@ function measureTextCellsUncached(text: string): number {
return total;
}

function measureTextCellsUncached(text: string): number {
return scanGraphemeClusters(text);
}

type GraphemeSlices = Readonly<{
starts: readonly number[];
ends: readonly number[];
prefixWidths: readonly number[];
}>;

function collectGraphemeSlices(text: string): GraphemeSlices {
const starts: number[] = [];
const ends: number[] = [];
const prefixWidths: number[] = [0];

scanGraphemeClusters(text, (start, end, width) => {
starts.push(start);
ends.push(end);
const prev = prefixWidths[prefixWidths.length - 1] ?? 0;
prefixWidths.push(prev + width);
});

return { starts, ends, prefixWidths };
}

function maxPrefixClustersWithinWidth(prefixWidths: readonly number[], maxWidth: number): number {
let low = 0;
let high = prefixWidths.length - 1;
let best = 0;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const width = prefixWidths[mid] ?? Number.POSITIVE_INFINITY;
if (width <= maxWidth) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}

return best;
}

function maxSuffixClustersWithinWidth(prefixWidths: readonly number[], maxWidth: number): number {
const clusterCount = prefixWidths.length - 1;
const totalWidth = prefixWidths[clusterCount] ?? 0;

let low = 0;
let high = clusterCount;
let best = 0;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const left = prefixWidths[clusterCount - mid] ?? 0;
const width = totalWidth - left;
if (width <= maxWidth) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}

return best;
}

/**
* Truncate text to fit within maxWidth cells, appending ellipsis if needed.
* Returns original text if it fits.
Expand All @@ -311,28 +381,10 @@ export function truncateWithEllipsis(text: string, maxWidth: number): string {
const targetWidth = maxWidth - 1;
if (targetWidth <= 0) return "…";

// Binary search for truncation point
// We need to find the longest prefix that fits within targetWidth
let low = 0;
let high = text.length;
let bestEnd = 0;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const prefix = text.slice(0, mid);
const width = measureTextCells(prefix);

if (width <= targetWidth) {
bestEnd = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}

// Handle edge case where even one character is too wide
if (bestEnd === 0) return "…";

const { ends, prefixWidths } = collectGraphemeSlices(text);
const bestClusters = maxPrefixClustersWithinWidth(prefixWidths, targetWidth);
if (bestClusters === 0) return "…";
const bestEnd = ends[bestClusters - 1] ?? 0;
return `${text.slice(0, bestEnd)}…`;
}

Expand Down Expand Up @@ -361,46 +413,18 @@ export function truncateMiddle(text: string, maxWidth: number): string {
const startLen = Math.ceil(available / 2);
const endLen = Math.floor(available / 2);

// Binary search for start portion
let low = 0;
let high = text.length;
let startEnd = 0;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const prefix = text.slice(0, mid);
const width = measureTextCells(prefix);

if (width <= startLen) {
startEnd = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}

// Binary search from end for end portion
low = 0;
high = text.length;
let endStart = text.length;

while (low <= high) {
const mid = Math.floor((low + high) / 2);
const suffix = text.slice(text.length - mid);
const width = measureTextCells(suffix);

if (width <= endLen) {
endStart = text.length - mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
const { starts, ends, prefixWidths } = collectGraphemeSlices(text);
const clusterCount = starts.length;
const startClusters = maxPrefixClustersWithinWidth(prefixWidths, startLen);
const endClusters = maxSuffixClustersWithinWidth(prefixWidths, endLen);
const endStartCluster = clusterCount - endClusters;

// Ensure we don't overlap
if (startEnd >= endStart) {
if (startClusters >= endStartCluster) {
return truncateWithEllipsis(text, maxWidth);
}

const startEnd = startClusters === 0 ? 0 : (ends[startClusters - 1] ?? 0);
const endStart = endClusters === 0 ? text.length : (starts[endStartCluster] ?? text.length);
return `${text.slice(0, startEnd)}…${text.slice(endStart)}`;
}
118 changes: 118 additions & 0 deletions packages/core/src/runtime/__tests__/focusZones.golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
computeZoneTraversal,
createFocusManagerState,
finalizeFocusForCommittedTreeWithZones,
finalizeFocusWithPreCollectedMetadata,
} from "../focus.js";
import type { FocusDirection, FocusMove, FocusZone } from "../focus.js";
import { createInstanceIdAllocator } from "../instance.js";
Expand Down Expand Up @@ -290,6 +291,55 @@ describe("Focus Zones - computeZoneTraversal", () => {
assert.equal(result.nextFocusedId, "a");
});

test("TAB skips empty zones instead of clearing focus", () => {
const zones = new Map<string, FocusZone>([
[
"zone1",
{
id: "zone1",
tabIndex: 0,
navigation: "linear",
columns: 1,
wrapAround: true,
focusableIds: ["a", "b"],
lastFocusedId: null,
},
],
[
"zone2",
{
id: "zone2",
tabIndex: 1,
navigation: "linear",
columns: 1,
wrapAround: true,
focusableIds: [],
lastFocusedId: null,
},
],
[
"zone3",
{
id: "zone3",
tabIndex: 2,
navigation: "linear",
columns: 1,
wrapAround: true,
focusableIds: ["c"],
lastFocusedId: null,
},
],
]);

const next = computeZoneTraversal(zones, "zone1", "next", [], new Map());
assert.equal(next.nextZoneId, "zone3");
assert.equal(next.nextFocusedId, "c");

const prev = computeZoneTraversal(zones, "zone3", "prev", [], new Map());
assert.equal(prev.nextZoneId, "zone1");
assert.equal(prev.nextFocusedId, "b");
});

test("zone remembers last focused id", () => {
const zones = new Map<string, FocusZone>([
[
Expand Down Expand Up @@ -728,3 +778,71 @@ describe("FocusManagerState - finalizeFocusForCommittedTreeWithZones", () => {
assert.deepEqual(nextState.trapStack, ["modal"]);
});
});

describe("FocusManagerState - finalizeFocusWithPreCollectedMetadata", () => {
test("trap reassignment clears stale active zone and blocks stale-zone arrow routing", () => {
const state = Object.freeze({
focusedId: "zone-item",
activeZoneId: "zone1",
zones: new Map<string, FocusZone>(),
trapStack: Object.freeze([]),
lastFocusedByZone: new Map<string, string>([["zone1", "zone-item"]]),
});

const focusList = ["zone-item", "modal-item"];
const collectedZones = new Map<string, CollectedZone>([
[
"zone1",
{
id: "zone1",
tabIndex: 0,
navigation: "linear",
columns: 1,
wrapAround: true,
focusableIds: ["zone-item"],
},
],
]);
const collectedTraps = new Map<string, CollectedTrap>([
[
"modal",
{
id: "modal",
active: true,
returnFocusTo: null,
initialFocus: "modal-item",
focusableIds: ["modal-item"],
},
],
]);

const nextState = finalizeFocusWithPreCollectedMetadata(
state,
focusList,
collectedZones,
collectedTraps,
);

assert.equal(nextState.focusedId, "modal-item");
assert.equal(nextState.activeZoneId, null);
assert.deepEqual(nextState.trapStack, ["modal"]);
assert.equal(nextState.lastFocusedByZone.get("zone1"), "zone-item");
assert.equal(nextState.zones.get("zone1")?.lastFocusedId, "zone-item");

const arrowResult = routeKeyWithZones(keyEvent(ZR_KEY_DOWN), {
focusedId: nextState.focusedId,
activeZoneId: nextState.activeZoneId,
focusList,
zones: nextState.zones,
lastFocusedByZone: nextState.lastFocusedByZone,
traps: collectedTraps,
trapStack: nextState.trapStack,
enabledById: new Map([
["zone-item", true],
["modal-item", true],
]),
});
assert.equal(arrowResult.nextFocusedId, undefined);
assert.equal(arrowResult.nextZoneId, undefined);
});
});
Loading
Loading