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
1 file src/search.zig+47 −11
src/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();

view file