a4d2b79b37cb

man pager help screen's i==0 row now emits exactly one line instead of two, so the help frame is exactly PAGE_ROWS tall and the redraw's cursorUp(PAGE_ROWS + FOOTER_ROWS) no longer drifts the frame up a row on every dismiss/redraw; dismissing help with any key now forces an immediate body+footer redraw instead of leaving stale help content until the next navigation key; extends the v3.4.5 ghosting-drift regression coverage to drawHelpBody and drawBody with newline-count tests against captured output (NINA-015)

time
2026-07-02 14:31 UTC · 3 months ago
machine
koh2
agent
claude-sonnet-5
session
fable audit fix — TUI raw mode and pager
parent
48628db291a4
1 file src/man.zig+62 −2
src/man.zig+62 −2
@@ -632,7 +632,11 @@632632             'h' => top = 0,633633             '?' => {634634                 showHelp();635-                redraw = false;635+                // showHelp leaves the help screen on the terminal until the636+                // next redraw; force one now instead of waiting for the next637+                // navigation key, so dismissing help doesn't leave stale638+                // help content on screen (NINA-015).639+                redraw = true;636640             },637641             else => redraw = false,638642         }@@ -907,11 +911,18 @@907911         .{ .key = "?", .desc = "this help" },908912         .{ .key = "q  /  esc", .desc = "leave the manpage" },909913     };914+    // Every iteration must emit exactly one row (one trailing "\n") — the915+    // i == 0 arm used to print a title line plus a blank spacer line916+    // ("\n\n") in a single iteration, so the help frame came out PAGE_ROWS+1917+    // rows tall while every redraw's `cursorUp(PAGE_ROWS + FOOTER_ROWS)`918+    // assumed exactly PAGE_ROWS, drifting the frame up a row on every help919+    // dismiss/redraw (same ghosting-drift bug class as the v3.4.5 body fix,920+    // NINA-015).910921     var i: usize = 0;911922     while (i < PAGE_ROWS) : (i += 1) {912923         output.raw("\x1b[K");913924         if (i == 0) {914-            output.p("  {s}{s}nina man · keys{s}\n\n", .{ c(BOLD), c(PINK), c(RESET) });925+            output.p("  {s}{s}nina man · keys{s}\n", .{ c(BOLD), c(PINK), c(RESET) });915926         } else if (i - 1 < pairs.len) {916927             const p = pairs[i - 1];917928             output.p("    {s}{s:<14}{s}  {s}{s}{s}\n", .{ c(GOLD), p.key, c(RESET), c(TEXT), p.desc, c(RESET) });@@ -919,4 +930,53 @@919930             output.raw("\n");920931         }921932     }933+}934+935+// --- Ghosting-drift regression tests (v3.4.5 fixed the body; this extends936+// coverage to the help screen, NINA-015) ---937+//938+// The redraw arithmetic in run()'s key loop (`cursorUp(PAGE_ROWS +939+// FOOTER_ROWS)`) only stays correct if every screen-drawing function emits940+// exactly the row count its name promises. These tests capture real output941+// through output.zig (the same module the TUI writes through) and count942+// newlines, so a regression that re-introduces an extra or missing row in943+// either function fails loudly instead of drifting the frame on-screen.944+945+fn newlineCount(bytes: []const u8) usize {946+    return std.mem.count(u8, bytes, "\n");947+}948+949+test "drawHelpBody emits exactly PAGE_ROWS rows regardless of PAGE_ROWS' value" {950+    const saved_page_rows = PAGE_ROWS;951+    defer PAGE_ROWS = saved_page_rows;952+953+    var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);954+    defer writer.deinit();955+    var env: std.process.Environ.Map = .init(std.testing.allocator);956+    defer env.deinit();957+    output.init(&writer.writer, std.testing.io, &env, false);958+959+    for ([_]u16{ 5, 12, MIN_PAGE_ROWS }) |rows| {960+        PAGE_ROWS = rows;961+        writer.clearRetainingCapacity();962+        drawHelpBody();963+        try std.testing.expectEqual(@as(usize, rows), newlineCount(writer.written()));964+    }965+}966+967+test "drawBody emits exactly PAGE_ROWS rows" {968+    const saved_page_rows = PAGE_ROWS;969+    defer PAGE_ROWS = saved_page_rows;970+    PAGE_ROWS = 6;971+972+    var writer: std.Io.Writer.Allocating = .init(std.testing.allocator);973+    defer writer.deinit();974+    var env: std.process.Environ.Map = .init(std.testing.allocator);975+    defer env.deinit();976+    output.init(&writer.writer, std.testing.io, &env, false);977+978+    const lines = [_][]const u8{ "one\n", "two\n", "three\n" };979+    drawBody(&lines, 0, lines.len, &.{}, 0);980+981+    try std.testing.expectEqual(@as(usize, PAGE_ROWS), newlineCount(writer.written()));922982 }\ No newline at end of file

view file