37f985da095d
add 250ms debounce to search query input via Io.Clock poll loop; stale results stay visible while typing — build + tests green
- time
- 2026-06-05 02:05 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- nina finishing brief
- parent
- debc6352f6d5
↓ nina-37f985da.tar.gzatlas at this save
files changed1 file
src/search.zig+47 −11src/search.zig+47 −11
@@ -10,6 +10,17 @@1010 const RESULT_ROWS: usize = 5;1111 const STDIN_FD = std.posix.STDIN_FILENO;1212 13+// Debounce window: a keystroke defers the nix search until typing pauses this14+// long. Without it, every character fired a full `nix search`, stalling mid-word.15+const DEBOUNCE_MS: i64 = 250;16+17+// Milliseconds elapsed since `since` on the monotonic (awake) clock. std has no18+// milliTimestamp in 0.16; this mirrors the Io.Clock pattern used in apply().19+fn elapsedMs(io: std.Io, since: std.Io.Timestamp) i64 {20+ const ns = @max(0, since.untilNow(io, .awake).toNanoseconds());21+ return @intCast(@divFloor(ns, 1_000_000));22+}23+1324 pub fn run(1425 gpa: std.mem.Allocator,1526 io: std.Io,@@ -44,15 +55,42 @@4455 renderWidget(query_buf[0..query_len], packages, opts, selection, mode);4556 output.flush();4657 47- // Key loop58+ // Key loop with debounced search. A keystroke marks the query dirty and59+ // stamps the time; the nix search fires only once typing pauses for60+ // DEBOUNCE_MS. Stale results stay on screen until the new search returns, so61+ // there is no blank flash mid-typing.4862 var chosen: ?types.SearchResult = null;63+ var query_dirty = false;64+ var query_changed_at = std.Io.Clock.awake.now(io);4965 loop: while (true) {66+ // Block until a key arrives, unless a search is pending — then wake when67+ // the debounce window closes so we can fire it.68+ var timeout_ms: i32 = -1;69+ if (query_dirty) {70+ const remaining = DEBOUNCE_MS - elapsedMs(io, query_changed_at);71+ timeout_ms = if (remaining <= 0) 0 else @intCast(remaining);72+ }73+74+ var pfd = [_]std.posix.pollfd{.{ .fd = STDIN_FD, .events = std.posix.POLL.IN, .revents = 0 }};75+ const ready = std.posix.poll(&pfd, timeout_ms) catch 0;76+77+ if (ready == 0) {78+ // Poll timed out: typing has paused long enough — run the search now.79+ if (query_dirty and elapsedMs(io, query_changed_at) >= DEBOUNCE_MS) {80+ query_dirty = false;81+ _ = arena.reset(.free_all);82+ packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);83+ output.cursorUp(WIDGET_LINES);84+ renderWidget(query_buf[0..query_len], packages, opts, selection, mode);85+ output.flush();86+ }87+ continue;88+ }89+5090 var buf: [4]u8 = undefined;5191 const n = std.posix.read(STDIN_FD, &buf) catch break;5292 if (n == 0) break;5393 54- var needs_search = false;55-5694 if (n >= 3 and buf[0] == 0x1b and buf[1] == '[') {5795 switch (buf[2]) {5896 'A' => if (selection > 0) { selection -= 1; }, // up@@ -91,7 +129,8 @@91129 127 => { // backspace92130 if (query_len > 0) {93131 query_len -= 1;94- needs_search = true;132+ query_dirty = true;133+ query_changed_at = std.Io.Clock.awake.now(io);95134 selection = 0;96135 }97136 },@@ -99,18 +138,15 @@99138 if (ch >= 32 and ch < 127 and query_len < query_buf.len - 1) {100139 query_buf[query_len] = ch;101140 query_len += 1;102- needs_search = true;141+ query_dirty = true;142+ query_changed_at = std.Io.Clock.awake.now(io);103143 selection = 0;104144 }105145 },106146 }107147 108- if (needs_search) {109- _ = arena.reset(.free_all);110- packages = doSearch(arena.allocator(), io, query_buf[0..query_len], mode, &opts);111- }112-113- // Move cursor up and redraw148+ // Redraw immediately so the query line and selection feel responsive; the149+ // search itself is deferred to the debounce tick above.114150 output.cursorUp(WIDGET_LINES);115151 renderWidget(query_buf[0..query_len], packages, opts, selection, mode);116152 output.flush();