Skip to content

Commit 3113b0a

Browse files
committed
Merge origin/main into fix/233-default-q-quit
2 parents 6e67f86 + 7d9d682 commit 3113b0a

File tree

6 files changed

+156
-4
lines changed

6 files changed

+156
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on Keep a Changelog and the project follows Semantic Version
99
### Bug Fixes
1010

1111
- **core/runtime**: Unhandled top-level `q`/`Q` and `Ctrl+C` inputs now stop the app by default, while preserving explicit keybinding handlers.
12+
- **native/detect**: Startup terminal probing now exits after a short DA1 drain window instead of waiting the full 500ms budget when XTVERSION never responds, reducing first-render delay on VTE-like terminals.
1213
- **core/constraints**: Constraint input signatures now include all required runtime dependencies, preventing stale cache reuse when unconstrained referenced widget geometry changes.
1314
- **core/layout**: Constraint resolution now performs bounded in-frame settle passes for deeper parent-dependent chains, eliminating first-frame/resize layout jump artifacts in nested constraint trees.
1415
- **core/layout**: Constraint and scroll override traversal now covers modal/layer slot children (`content`/`actions`) so display and geometry overrides apply consistently to overlay subtrees.

packages/core/src/app/__tests__/widgetRenderer.integration.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type { DrawlistBuilder } from "../../drawlist/index.js";
1010
import type { ZrevEvent } from "../../events.js";
1111
import { type VNode, defineWidget, ui } from "../../index.js";
1212
import { DEFAULT_TERMINAL_CAPS } from "../../terminalCaps.js";
13+
import { createTestRenderer } from "../../testing/index.js";
1314
import { defaultTheme } from "../../theme/defaultTheme.js";
1415
import { TOAST_HEIGHT, getToastActionFocusId } from "../../widgets/toast.js";
1516
import { createApp } from "../createApp.js";
@@ -1089,6 +1090,67 @@ describe("WidgetRenderer integration battery", () => {
10891090
assert.deepEqual(events, []);
10901091
});
10911092

1093+
test("modal actions preserve declared order and click behavior", () => {
1094+
const backend = createNoopBackend();
1095+
const renderer = new WidgetRenderer<void>({
1096+
backend,
1097+
requestRender: () => {},
1098+
});
1099+
1100+
const calls: string[] = [];
1101+
const vnode = ui.modal({
1102+
id: "confirm-quit",
1103+
title: "Confirm quit",
1104+
content: ui.text("Are you sure?"),
1105+
actions: [
1106+
ui.button({
1107+
id: "cancel",
1108+
label: "Cancel",
1109+
onPress: () => calls.push("cancel"),
1110+
}),
1111+
ui.button({
1112+
id: "confirm",
1113+
label: "Quit",
1114+
onPress: () => calls.push("confirm"),
1115+
}),
1116+
],
1117+
});
1118+
1119+
const res = renderer.submitFrame(
1120+
() => vnode,
1121+
undefined,
1122+
{ cols: 80, rows: 24 },
1123+
defaultTheme,
1124+
noRenderHooks(),
1125+
);
1126+
assert.ok(res.ok);
1127+
const textSnapshot = createTestRenderer({ viewport: { cols: 80, rows: 24 } })
1128+
.render(vnode)
1129+
.toText();
1130+
assert.equal(textSnapshot.includes("Cancel"), true);
1131+
1132+
const rects = renderer.getRectByIdIndex();
1133+
const cancelRect = rects.get("cancel");
1134+
const confirmRect = rects.get("confirm");
1135+
assert.ok(cancelRect !== undefined, "cancel rect should exist");
1136+
assert.ok(confirmRect !== undefined, "confirm rect should exist");
1137+
if (!cancelRect || !confirmRect) return;
1138+
1139+
assert.equal(cancelRect.x < confirmRect.x, true, "cancel should render left of confirm");
1140+
1141+
const cancelX = cancelRect.x + Math.max(0, Math.floor((cancelRect.w - 1) / 2));
1142+
const cancelY = cancelRect.y;
1143+
renderer.routeEngineEvent(mouseDownEvent(cancelX, cancelY));
1144+
renderer.routeEngineEvent(mouseEvent(cancelX, cancelY, 4));
1145+
1146+
const confirmX = confirmRect.x + Math.max(0, Math.floor((confirmRect.w - 1) / 2));
1147+
const confirmY = confirmRect.y;
1148+
renderer.routeEngineEvent(mouseDownEvent(confirmX, confirmY));
1149+
renderer.routeEngineEvent(mouseEvent(confirmX, confirmY, 4));
1150+
1151+
assert.deepEqual(calls, ["cancel", "confirm"]);
1152+
});
1153+
10921154
test("splitPane double-click toggles collapse via onCollapse", () => {
10931155
const backend = createNoopBackend();
10941156
const renderer = new WidgetRenderer<void>({

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

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ import {
1111
parseCommandHeaders,
1212
parseInternedStrings,
1313
} from "../../__tests__/drawlistDecode.js";
14-
import { type VNode, createDrawlistBuilder } from "../../index.js";
14+
import { type VNode, createDrawlistBuilder, ui } from "../../index.js";
1515
import { layout } from "../../layout/layout.js";
1616
import { commitVNodeTree } from "../../runtime/commit.js";
1717
import { createInstanceIdAllocator } from "../../runtime/instance.js";
18+
import { createTestRenderer } from "../../testing/renderer.js";
1819
import { renderToDrawlist } from "../renderToDrawlist.js";
1920

2021
const decoder = new TextDecoder();
@@ -296,6 +297,47 @@ function expectBlob(frame: ParsedFrame, blobIndex: number): TextRunBlob {
296297
return blob;
297298
}
298299

300+
describe("renderer text - wrap newline handling", () => {
301+
test("wrap=true renders explicit newline lines on separate rows", () => {
302+
const renderer = createTestRenderer({ viewport: { cols: 20, rows: 4 } });
303+
const frame = renderer.render(
304+
ui.text("First line\nSecond line", {
305+
id: "wrapped",
306+
wrap: true,
307+
}),
308+
);
309+
const lines = frame.toText().split("\n");
310+
311+
assert.equal(lines[0]?.includes("First line"), true);
312+
assert.equal(lines[1]?.includes("Second line"), true);
313+
314+
const wrapped = frame.findById("wrapped");
315+
assert.notEqual(wrapped, null);
316+
assert.equal(wrapped?.rect.y, 0);
317+
assert.ok((wrapped?.rect.h ?? 0) >= 2);
318+
});
319+
320+
test("wrap=true preserves blank lines from double newlines", () => {
321+
const renderer = createTestRenderer({ viewport: { cols: 20, rows: 6 } });
322+
const frame = renderer.render(
323+
ui.text("Alpha\n\nOmega", {
324+
id: "wrapped-blank",
325+
wrap: true,
326+
}),
327+
);
328+
const lines = frame.toText().split("\n");
329+
330+
assert.equal(lines[0]?.includes("Alpha"), true);
331+
assert.equal((lines[1] ?? "").trim(), "");
332+
assert.equal(lines[2]?.includes("Omega"), true);
333+
334+
const wrapped = frame.findById("wrapped-blank");
335+
assert.notEqual(wrapped, null);
336+
assert.equal(wrapped?.rect.y, 0);
337+
assert.ok((wrapped?.rect.h ?? 0) >= 3);
338+
});
339+
});
340+
299341
describe("renderer text - transform ANSI styling", () => {
300342
const gradientTransform = (): string => "\u001b[31mA\u001b[32mB\u001b[0m";
301343

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
c0849ae29483322623d4ab564877a8940896affb
1+
97a7b907cc6c5a2886fdaef2ea82c8f9e337013e

packages/native/vendor/zireael/src/core/zr_detect.c

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ enum {
1818
ZR_DETECT_READ_ACCUM_CAP = 4096u,
1919
ZR_DETECT_QUERY_TIMEOUT_MS = 100u,
2020
ZR_DETECT_TOTAL_TIMEOUT_MS = 500u,
21+
ZR_DETECT_DA1_DRAIN_TIMEOUT_MS = 20u,
2122
ZR_DETECT_DECRQM_SET = 1u,
2223
};
2324

@@ -756,6 +757,23 @@ static int32_t zr_detect_read_timeout_slice(uint64_t start_ms, uint32_t spent_ms
756757
return remaining;
757758
}
758759

760+
static int32_t zr_detect_remaining_da1_drain_budget(uint32_t spent_ms) {
761+
if (spent_ms >= (uint32_t)ZR_DETECT_DA1_DRAIN_TIMEOUT_MS) {
762+
return 0;
763+
}
764+
return (int32_t)((uint32_t)ZR_DETECT_DA1_DRAIN_TIMEOUT_MS - spent_ms);
765+
}
766+
767+
static int32_t zr_detect_clamp_timeout_budget(int32_t timeout_ms, int32_t budget_ms) {
768+
if (timeout_ms <= 0 || budget_ms <= 0) {
769+
return 0;
770+
}
771+
if (timeout_ms > budget_ms) {
772+
return budget_ms;
773+
}
774+
return timeout_ms;
775+
}
776+
759777
static zr_terminal_id_t zr_detect_fallback_terminal_id(plat_t* plat) {
760778
zr_terminal_id_t id = ZR_TERM_UNKNOWN;
761779
if (plat_guess_terminal_id(plat, &id) != ZR_OK) {
@@ -847,8 +865,25 @@ zr_result_t zr_detect_probe_terminal(plat_t* plat, const plat_caps_t* baseline_c
847865

848866
uint64_t start_ms = plat_now_ms();
849867
uint32_t timeout_spent_ms = 0u;
868+
/* DA1 acts as a probe sentinel; after it arrives, drain briefly then stop. */
869+
uint8_t da1_responded = 0u;
870+
uint64_t da1_seen_ms = 0u;
871+
uint32_t da1_drain_spent_ms = 0u;
850872
while (true) {
851-
const int32_t timeout_ms = zr_detect_read_timeout_slice(start_ms, timeout_spent_ms);
873+
int32_t timeout_ms = zr_detect_read_timeout_slice(start_ms, timeout_spent_ms);
874+
if (da1_responded != 0u) {
875+
const uint64_t now_ms = plat_now_ms();
876+
uint32_t da1_elapsed_ms = 0u;
877+
if (now_ms > da1_seen_ms) {
878+
const uint64_t delta_ms = now_ms - da1_seen_ms;
879+
da1_elapsed_ms = (delta_ms > UINT32_MAX) ? UINT32_MAX : (uint32_t)delta_ms;
880+
}
881+
if (da1_elapsed_ms > da1_drain_spent_ms) {
882+
da1_drain_spent_ms = da1_elapsed_ms;
883+
}
884+
const int32_t da1_budget_ms = zr_detect_remaining_da1_drain_budget(da1_drain_spent_ms);
885+
timeout_ms = zr_detect_clamp_timeout_budget(timeout_ms, da1_budget_ms);
886+
}
852887
if (timeout_ms <= 0) {
853888
break;
854889
}
@@ -860,6 +895,9 @@ zr_result_t zr_detect_probe_terminal(plat_t* plat, const plat_caps_t* baseline_c
860895
}
861896
if (n == 0) {
862897
timeout_spent_ms += (uint32_t)timeout_ms;
898+
if (da1_responded != 0u) {
899+
da1_drain_spent_ms += (uint32_t)timeout_ms;
900+
}
863901
continue;
864902
}
865903

@@ -871,6 +909,15 @@ zr_result_t zr_detect_probe_terminal(plat_t* plat, const plat_caps_t* baseline_c
871909
memcpy(collected + collected_len, chunk, copy_len);
872910
collected_len += copy_len;
873911
}
912+
if (da1_responded == 0u) {
913+
zr_detect_parsed_t partial;
914+
zr_detect_parsed_reset(&partial);
915+
(void)zr_detect_parse_responses(collected, collected_len, &partial);
916+
da1_responded = partial.da1_responded;
917+
if (da1_responded != 0u) {
918+
da1_seen_ms = plat_now_ms();
919+
}
920+
}
874921
if (collected_len == sizeof(collected)) {
875922
break;
876923
}

0 commit comments

Comments
 (0)