: nply/wmenu

commit b5a00bb375a1c1080a491000d76712486b6dae9d
parent 92508a34be083841836724ac26566f76f70f894e
Author: Bharatvaj Hemanth <bharatvaj@yahoo.com>
Date: Tue, 13 Oct 2026 21:46:44 +0530

Append streamed list rows without resetting scroll
4 files changed, 97 insertions(+), 17 deletions(-)
M
HACKING
|
9
+++++----
M
Makefile
|
4
++++
A
test_stream.c
|
37
+++++++++++++++++++++++++++++++++++++
M
wmenu.c
|
64
+++++++++++++++++++++++++++++++++++++++++++++++++++-------------
diff --git a/HACKING b/HACKING
@@ -77,7 +77,7 @@ Use -default-controls for normal Windows edit behavior.
 
 Release checklist
 -----------------
-1. Run make.
+1. Run make and make test (native ListBox append/selection regression).
 2. Check:
        .\\wmenu.exe -help
        .\\wmenu.exe -version

@@ -97,9 +97,10 @@ Known simplifications
 ---------------------
 - Default input/output encoding is UTF-8. Use `-enc ansi` or `-enc oem` for
   legacy Windows pipes such as classic `cmd.exe` output.
-- The ListBox displays at most LIST_LIMIT rows. This keeps huge streams (for
-  example 100MB+ logs) responsive instead of trying to shove every line into a
-  Win32 list control.
+- The ListBox displays at most LIST_LIMIT rows. Stream updates append new
+  unfiltered rows rather than resetting the control; filtered results rebuild
+  only if the displayed matches change. The native ListBox owns scrolling.
+  This keeps huge streams (for example 100MB+ logs) responsive.
 - Filtering scans at most SCAN_LIMIT rows and stops after LIST_LIMIT matches.
   This intentionally favors staying interactive over exhaustive ranking on
   massive inputs.
diff --git a/Makefile b/Makefile
@@ -9,6 +9,10 @@ CFLAGS = $(DEBUG)
 wmenu.exe: wmenu.c arg.h deps/fzy/src/match.c deps/fzy/config.h deps/fzy/src/strings.h
 	cl wmenu.c deps/fzy/src/match.c /I deps/fzy/src $(CFLAGS) user32.lib comctl32.lib gdi32.lib
 
+test: test_stream.c wmenu.c arg.h deps/fzy/src/match.c deps/fzy/config.h deps/fzy/src/strings.h
+	cl test_stream.c deps/fzy/src/match.c /I deps/fzy/src $(CFLAGS) user32.lib comctl32.lib gdi32.lib /Fe:test_stream.exe
+	./test_stream.exe
+
 deps/fzy/config.h: deps/fzy/src/config.def.h
 	printf '#include "src/config.def.h"\n' > deps/fzy/config.h
 
diff --git a/test_stream.c b/test_stream.c
@@ -0,0 +1,37 @@
+#define main wmenu_main
+#include "wmenu.c"
+#undef main
+#include <assert.h>
+
+int main(void)
+{
+    InitializeCriticalSection(&items_lock);
+    main_hwnd = CreateWindowW(L"STATIC", L"", WS_OVERLAPPEDWINDOW, 0, 0, 400, 300, NULL, NULL, NULL, NULL);
+    list_hwnd = CreateWindowW(L"LISTBOX", L"", WS_CHILD | WS_VSCROLL, 0, 0, 400, 300, main_hwnd, NULL, NULL, NULL);
+    assert(list_hwnd);
+    for (int i = 0; i < 10; ++i) {
+        char buf[32];
+        sprintf(buf, "item-%d", i);
+        add_item(buf);
+    }
+    refill_list("");
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 10);
+    SendMessageW(list_hwnd, LB_SETCURSEL, 5, 0);
+    for (int i = 10; i < LIST_LIMIT + 10; ++i) {
+        char buf[32];
+        sprintf(buf, "item-%d", i);
+        add_item(buf);
+    }
+    refill_list("");
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == LIST_LIMIT);
+    assert(SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0) == 5);
+    refill_list("");
+    assert(SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0) == 5);
+    refill_list("item-9");
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) > 0);
+    refill_list("");
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == LIST_LIMIT);
+    DestroyWindow(main_hwnd);
+    DeleteCriticalSection(&items_lock);
+    return 0;
+}
diff --git a/wmenu.c b/wmenu.c
@@ -121,6 +121,8 @@ static DWORD WINAPI read_stdin_thread(void *unused)
 }
 
 struct ranked_item { int index; score_t score; };
+static int displayed[LIST_LIMIT], displayed_count;
+static bool displayed_filtered;
 
 static int rank_cmp(const void *a, const void *b)
 {

@@ -131,15 +133,33 @@ static int rank_cmp(const void *a, const void *b)
 static void refill_list(const char *query)
 {
     struct ranked_item ranked[LIST_LIMIT];
-    int n = 0;
-    SendMessageW(list_hwnd, LB_RESETCONTENT, 0, 0);
+    int n = 0, old_selection = (int)SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0);
+    int old_item = old_selection >= 0 && old_selection < displayed_count ? displayed[old_selection] : -1;
+    int old_top = (int)SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0);
+    bool filtered = query && *query;
+    bool was_filtered = displayed_filtered;
     EnterCriticalSection(&items_lock);
-    if (!query || !*query) {
-        for (int i = 0; i < item_count && n < LIST_LIMIT; ++i) {
+    if (!filtered) {
+        if (was_filtered) {
+            SendMessageW(list_hwnd, WM_SETREDRAW, FALSE, 0);
+            SendMessageW(list_hwnd, LB_RESETCONTENT, 0, 0);
+            displayed_count = 0;
+            displayed_filtered = false;
+        }
+        /* Append only new rows. LB_ADDSTRING repaints affected rows itself. */
+        for (int i = displayed_count; i < item_count && i < LIST_LIMIT; ++i) {
             wchar_t *w = widen(items[i]);
             SendMessageW(list_hwnd, LB_ADDSTRING, 0, (LPARAM)w);
+            displayed[displayed_count++] = i;
             free(w);
-            ++n;
+        }
+        if (was_filtered) {
+            SendMessageW(list_hwnd, LB_SETCURSEL,
+                old_item >= 0 && old_item < displayed_count ? old_item : 0, 0);
+            SendMessageW(list_hwnd, WM_SETREDRAW, TRUE, 0);
+            InvalidateRect(list_hwnd, NULL, TRUE);
+        } else if (displayed_count && old_selection < 0) {
+            SendMessageW(list_hwnd, LB_SETCURSEL, 0, 0);
         }
     } else {
         int scan = item_count < SCAN_LIMIT ? item_count : SCAN_LIMIT;

@@ -150,14 +170,30 @@ static void refill_list(const char *query)
             }
         }
         qsort(ranked, n, sizeof(*ranked), rank_cmp);
-        for (int i = 0; i < n; ++i) {
-            wchar_t *w = widen(items[ranked[i].index]);
-            SendMessageW(list_hwnd, LB_ADDSTRING, 0, (LPARAM)w);
-            free(w);
+        bool changed = !displayed_filtered || n != displayed_count;
+        for (int i = 0; !changed && i < n; ++i)
+            if (ranked[i].index != displayed[i]) changed = true;
+        if (changed) {
+            int selection = n ? 0 : -1;
+            SendMessageW(list_hwnd, WM_SETREDRAW, FALSE, 0);
+            SendMessageW(list_hwnd, LB_RESETCONTENT, 0, 0);
+            for (int i = 0; i < n; ++i) {
+                wchar_t *w = widen(items[ranked[i].index]);
+                displayed[i] = ranked[i].index;
+                SendMessageW(list_hwnd, LB_ADDSTRING, 0, (LPARAM)w);
+                if (displayed[i] == old_item) selection = i;
+                free(w);
+            }
+            displayed_count = n;
+            displayed_filtered = true;
+            if (selection >= 0) SendMessageW(list_hwnd, LB_SETCURSEL, selection, 0);
+            if (old_top > 0 && selection >= 0 && old_item >= 0 && displayed[selection] == old_item)
+                SendMessageW(list_hwnd, LB_SETTOPINDEX, old_top, 0);
+            SendMessageW(list_hwnd, WM_SETREDRAW, TRUE, 0);
+            InvalidateRect(list_hwnd, NULL, TRUE);
         }
     }
     LeaveCriticalSection(&items_lock);
-    if (n) SendMessageW(list_hwnd, LB_SETCURSEL, 0, 0);
 }
 
 static char *current_text(void)

@@ -353,7 +389,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
         InterlockedExchange(&refresh_pending, 0);
         if (list_hwnd && loading_items) {
             char *q = text_encoded(input_hwnd);
-            refill_list(q);
+            if (q) refill_list(q);
             free(q);
         }
         return 0;

@@ -362,8 +398,10 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
         KillTimer(hwnd, TIMER_PROGRESS);
         if (progress_hwnd) ShowWindow(progress_hwnd, SW_HIDE);
         if (list_hwnd) {
+            char *q = text_encoded(input_hwnd);
             SetWindowPos(list_hwnd, NULL, 0, S(28), win_w, win_h - S(28), SWP_NOZORDER);
-            refill_list("");
+            if (q) refill_list(q);
+            free(q);
         }
         return 0;
     case WM_TIMER:

@@ -375,7 +413,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
         return 0;
     case WM_COMMAND:
         if (LOWORD(wp) == ID_LABEL) return 0;
-        if (LOWORD(wp) == ID_EDIT && HIWORD(wp) == EN_CHANGE && has_items && !loading_items) {
+        if (LOWORD(wp) == ID_EDIT && HIWORD(wp) == EN_CHANGE && has_items) {
             char *q = text_encoded(input_hwnd);
             refill_list(q);
             free(q);