: nply/wmenu

commit 40397a4342d58a3cdbafac60f5da7e0355e60637
parent c4d751181102f2d6ca8243a376d2add3821159ab
Author: Bharatvaj Hemanth <bharatvaj@yahoo.com>
Date: Tue, 13 Oct 2026 22:22:55 +0530

Preserve list selection and viewport during stream updates
3 files changed, 47 insertions(+), 11 deletions(-)
M
HACKING
|
6
++++--
M
test_stream.c
|
29
+++++++++++++++++++++++++----
M
wmenu.c
|
23
++++++++++++++++++-----
diff --git a/HACKING b/HACKING
@@ -111,8 +111,10 @@ Known simplifications
   not score-ranked). No silent row cap. LB_SETCOUNT is wrapped with redraw
   suppression: unchanged visible rows are not invalidated as the row count
   grows; only newly visible rows are, and those are painted immediately so
-  queued stream messages cannot starve WM_PAINT. The bottom bar shows search
-  progress after stdin completes.
+  queued stream messages cannot starve WM_PAINT. Restore the selection and
+  top row after LB_SETCOUNT (Windows can otherwise jump to the end), and
+  preserve pending selection paints when validating the viewport. The bottom
+  bar shows search progress after stdin completes.
 - lnav inspiration: its logfile index tracks lines separately from the view
   (https://github.com/tstack/lnav/blob/master/src/logfile.hh, lf_index and
   read_line); its text view requests counts and rows from a text sub-source
diff --git a/test_stream.c b/test_stream.c
@@ -4,9 +4,14 @@
 #include <assert.h>
 
 static int draw_calls;
+static bool painted_selected[16];
 static LRESULT CALLBACK TestWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
 {
-    if (msg == WM_DRAWITEM) ++draw_calls;
+    if (msg == WM_DRAWITEM) {
+        DRAWITEMSTRUCT *draw = (DRAWITEMSTRUCT *)lp;
+        ++draw_calls;
+        if (draw->itemID < 16) painted_selected[draw->itemID] = (draw->itemState & ODS_SELECTED) != 0;
+    }
     return WndProc(hwnd, msg, wp, lp);
 }
 

@@ -55,6 +60,18 @@ int main(int argc, char **argv)
     }
     refill_list("");
     assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 60000);
+    SendMessageW(list_hwnd, LB_SETCURSEL, 0, 0);
+    UpdateWindow(list_hwnd);
+    list_move(5);
+    add_item("extra-row");
+    refill_list(""); /* Count update before the selection's WM_PAINT. */
+    UpdateWindow(list_hwnd);
+    assert(SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0) == 5);
+    assert(SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0) == 0);
+    assert(!painted_selected[0] && painted_selected[5]);
+    list_move(-5);
+    assert(SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0) == 0);
+    assert(painted_selected[0] && !painted_selected[5]);
     SendMessageW(list_hwnd, LB_SETCURSEL, 59999, 0);
     char *last = current_text();
     assert(last && !strncmp(last, "item-59999", 10));

@@ -92,14 +109,18 @@ int main(int argc, char **argv)
     DeleteDC(dc);
     ReleaseDC(NULL, screen);
     refill_list("");
-    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 60000);
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 60001);
     ShowWindow(main_hwnd, SW_SHOWNOACTIVATE);
     ShowWindow(list_hwnd, SW_SHOWNOACTIVATE);
     UpdateWindow(list_hwnd);
     assert(!GetUpdateRect(list_hwnd, NULL, FALSE));
+    SendMessageW(list_hwnd, LB_SETCURSEL, 59999, 0);
+    int kept_top = (int)SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0);
     add_item("item-60000");
     refill_list("");
-    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 60001);
+    assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 60002);
+    assert(SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0) == 59999);
+    assert(SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0) == kept_top);
     RECT dirty;
     assert(!GetUpdateRect(list_hwnd, &dirty, FALSE) || IsRectEmpty(&dirty));
     /* Count/scrollbar may update, but the existing client rows stay untouched. */

@@ -108,7 +129,7 @@ int main(int argc, char **argv)
     input_done = false;
     add_item("tail-special");
     while (search_pos < item_count) search_step();
-    assert(match_count == 1 && matches[0] == 60001);
+    assert(match_count == 1 && matches[0] == 60002);
     input_done = true;
     update_progress();
     assert(SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0) == 1);
diff --git a/wmenu.c b/wmenu.c
@@ -141,24 +141,29 @@ static void update_count(void)
     int selected = (int)SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0);
     int top = (int)SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0);
     int height = (int)SendMessageW(list_hwnd, LB_GETITEMHEIGHT, 0, 0);
-    RECT client;
+    RECT client, pending;
+    bool had_pending = GetUpdateRect(list_hwnd, &pending, FALSE) && !IsRectEmpty(&pending);
     GetClientRect(list_hwnd, &client);
     SendMessageW(list_hwnd, WM_SETREDRAW, FALSE, 0);
     SendMessageW(list_hwnd, LB_SETCOUNT, count, 0);
-    if (count && selected < 0) SendMessageW(list_hwnd, LB_SETCURSEL, 0, 0);
+    if (count) {
+        SendMessageW(list_hwnd, LB_SETCURSEL, selected >= 0 && selected < count ? selected : 0, 0);
+        if (top > 0 || SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0) != 0)
+            SendMessageW(list_hwnd, LB_SETTOPINDEX, top < count ? top : count - 1, 0);
+    }
     SendMessageW(list_hwnd, WM_SETREDRAW, TRUE, 0);
     /* LB_SETCOUNT may invalidate everything. Only newly visible rows need paint. */
     RedrawWindow(list_hwnd, NULL, NULL, RDW_VALIDATE | RDW_NOCHILDREN);
     int new_top = (int)SendMessageW(list_hwnd, LB_GETTOPINDEX, 0, 0);
     if (top != new_top || count < old_count || height <= 0) {
         InvalidateRect(list_hwnd, NULL, FALSE);
-        UpdateWindow(list_hwnd);
     } else if (old_count < new_top + (client.bottom - client.top + height - 1) / height) {
         client.top += (old_count - new_top) * height;
         if (client.top < 0) client.top = 0;
         InvalidateRect(list_hwnd, &client, FALSE);
-        UpdateWindow(list_hwnd); /* Posted stream messages must not starve WM_PAINT. */
     }
+    if (had_pending) InvalidateRect(list_hwnd, &pending, FALSE);
+    UpdateWindow(list_hwnd); /* Do not starve new rows or selection paints. */
 }
 
 static void update_progress(void)

@@ -271,12 +276,20 @@ static void list_move(int delta)
     if (!has_items) return;
     LRESULT count = SendMessageW(list_hwnd, LB_GETCOUNT, 0, 0);
     LRESULT cur = SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0);
+    LRESULT previous = cur;
     if (count <= 0) return;
     if (cur < 0) cur = delta > 0 ? -1 : count;
     cur += delta;
     if (cur < 0) cur = 0;
     if (cur >= count) cur = count - 1;
+    if (cur == previous) return;
     SendMessageW(list_hwnd, LB_SETCURSEL, cur, 0);
+    RECT rect;
+    if (previous >= 0 && SendMessageW(list_hwnd, LB_GETITEMRECT, previous, (LPARAM)&rect) != LB_ERR)
+        InvalidateRect(list_hwnd, &rect, FALSE);
+    if (SendMessageW(list_hwnd, LB_GETITEMRECT, cur, (LPARAM)&rect) != LB_ERR)
+        InvalidateRect(list_hwnd, &rect, FALSE);
+    UpdateWindow(list_hwnd);
 }
 
 static void edit_key(WORD key) { SendMessageW(input_hwnd, WM_KEYDOWN, key, 0); }

@@ -455,7 +468,7 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
     case WM_DRAWITEM: {
         DRAWITEMSTRUCT *draw = (DRAWITEMSTRUCT *)lp;
         if (draw->CtlID != ID_COMBO || draw->itemID == (UINT)-1) break;
-        bool selected_row = (draw->itemState & ODS_SELECTED) != 0;
+        bool selected_row = (int)draw->itemID == (int)SendMessageW(list_hwnd, LB_GETCURSEL, 0, 0);
         FillRect(draw->hDC, &draw->rcItem,
                  GetSysColorBrush(selected_row ? COLOR_HIGHLIGHT : COLOR_WINDOW));
         EnterCriticalSection(&items_lock);