fix: implement partial/dirty-region TUI rendering to stop full-screen flicker
ober
6db9488f0027097a0cbc41e5dc73eb134bb10e10
new file mode 100644 --- /dev/null +++ b/no-blinky.md @@ -0,0 +1,127 @@ +# No-Blinky Fix: Eliminate TUI Screen Flickering + +## Problem Summary + +**Every keystroke causes the entire screen to flicker/blink**, which is extremely annoying for users. + +## Root Cause Analysis + +The flickering is caused by the TUI redrawing **the entire screen on every single event**. Even after removing the redundant `tb-clear!` call, `draw!` was still: + +1. Filling the whole screen with `fill-rect!` +2. Redrawing all three panels (conversations, thread, details) +3. Redrawing the composer, status bar, cursor, etc. + +`tb-present!` then emits terminal escape sequences for every cell that changed. Because the whole UI was being rewritten, many cells differed between frames, producing a visible full-screen flash on every keystroke or cursor movement. + +## Solution Implemented + +**Partial / dirty-region rendering.** Instead of repainting everything on every event, `draw!` now compares the current state to a snapshot of the last-drawn state and only repaints the regions that actually changed. + +### Code Changes + +**File:** `signal/tui/main.ss` + +1. **Added `last-drawn` field to `tui-state`** (line 54) — a hashtable that stores the values of fields that affect rendering. + +2. **Added `compute-dirty-regions`** (around line 2575) — compares current state against `last-drawn` and returns a list of dirty region symbols: + - `'all` — full redraw (first draw, mode change, resize, modal pages) + - `'composer` — composer input line + - `'status` — status bar + - `'conversations` — left conversation list panel + - `'thread` — middle message thread panel + - `'details` — right details panel + - `'mention` — mention popup + +3. **Added `update-last-drawn!`** — snapshots the current state after each draw. + +4. **Modified `draw!`** (around line 2645) — when dirty list is `'(all)`, does the original full-screen repaint; otherwise it only calls the draw functions for the dirty regions. + +5. **Removed redundant `tb-clear!`** (already done in the first attempt) — no longer needed because we now avoid rewriting the whole back buffer every frame. + +### What Gets Redrawn When + +| User action | Regions repainted | +|---|---| +| Type a letter in composer | `composer` (+ `mention` if @-popup changes) | +| Delete a character | `composer` | +| Move up/down between conversations | `conversations`, `thread`, `details` | +| Scroll message thread | `thread` | +| Receive a message | `thread`, `conversations` | +| Typing indicator appears/disappears | `thread` | +| Status text changes | `status` | +| Switch mode (Ctrl-N, Ctrl-F, etc.) | `'all` (full redraw) | +| Resize terminal | `'all` (full redraw) | + +## Edge Cases & Verification + +### `tb-invalidate!` / Corruption Healing + +The existing 10-second `tb-invalidate!` call is preserved. On invalidate frames `tb-present!` repaints the whole terminal, healing any display corruption. The state snapshot does not interfere with this. + +### Modal Pages + +Modal pages (`new-message`, `search`, `attach`, `captcha`, `theme`) currently do a full redraw on every event. They are short-lived and simple, so this is acceptable. The main chat mode — where users spend most of their time — gets the optimization. + +### Resize + +Resize changes `width`/`height`, which triggers `'all` and does a full redraw. + +### Mentions + +The mention popup floats over the thread panel. If the thread is redrawn while a mention popup is active, the popup is redrawn on top so it is not erased. + +## Build Commands + +```bash +# Build the project +make binary + +# Run tests +make test + +# Test the TUI (requires signal-cli setup) +./jerboa-signal tui --account +1234567890 +``` + +## Testing Checklist + +After implementing the fix, verify: + +- [ ] Typing in the composer doesn't cause screen flicker +- [ ] Scrolling through conversations is smooth +- [ ] Scrolling through messages is smooth +- [ ] Switching between modes (Ctrl-N, Ctrl-S, Ctrl-A) works correctly +- [ ] Resizing terminal window works correctly +- [ ] No visual artifacts after 10+ seconds of use (invalidate interval) +- [ ] Status bar updates don't cause flicker +- [ ] Incoming messages render smoothly +- [ ] Mention popup opens/closes without artifacts + +## Why the First Attempt Wasn't Enough + +The first attempt removed `tb-clear!`, which was correct but insufficient. Because `draw!` still rewrote the entire back buffer every frame, termbox still saw most cells as changing and emitted a large burst of escape sequences on every event. True flicker elimination requires avoiding the full back-buffer rewrite, not just the clear call. + +## Related Code References + +- **termbox2 FFI:** `signal/tui/ffi.ss` +- **Event loop:** `signal/tui/main.ss` lines 398-414 +- **`compute-dirty-regions`:** `signal/tui/main.ss` lines ~2575 +- **`update-last-drawn!`:** `signal/tui/main.ss` lines ~2615 +- **`draw!` function:** `signal/tui/main.ss` lines ~2645 +- **`tui-state` struct:** `signal/tui/main.ss` lines 48-54 + +## Success Criteria + +The fix is successful when: +1. Users can type continuously without any visible screen flicker +2. Conversation navigation is smooth +3. All existing functionality continues to work (no regressions) +4. No visual artifacts appear during normal use +5. Terminal resize and periodic invalidate still work correctly + +--- + +**Implementation time:** ~30 minutes +**Testing time:** 10 minutes +**Risk level:** Medium (adds state tracking, but tests pass and full redraw still used for mode/resize/first draw) --- a/signal/tui/main.ss +++ b/signal/tui/main.ss @@ -50,7 +50,8 @@ conversations selected-index mode picker-query picker-index removed aliases logdb live-capture? resend challenge rate-limits send-events capture-events capture-stop-box capture-worker - thread-scroll conv-scroll mention-active? mention-index mention-candidates)) + thread-scroll conv-scroll mention-active? mention-index mention-candidates + last-drawn)) ;; Open the encrypted message log. Passphrase comes from JERBOA_SIGNAL_DB_KEY ;; if set (enables headless use), otherwise we prompt. A blank passphrase or a @@ -205,13 +206,14 @@ (make-hashtable equal-hash equal?) (make-channel/buf 64) (make-channel/buf 256) - (box #f) - #f - 0 - 0 + (box #f) #f - 0 - '())) + 0 + 0 + #f + 0 + '() + (make-hashtable equal-hash equal?))) ;; Pull recent rows out of the encrypted log so search and scrollback cover ;; previous sessions, not just this one. Runs after contact/group seeding so @@ -2570,6 +2572,86 @@ ;; --- Drawing --- + ;; Compare the current state against the last-drawn snapshot to decide which + ;; screen regions actually changed. This lets us repaint only the composer + ;; when the user is typing, only the conversation list + thread when the + ;; selection moves, etc. -- avoiding the full-screen flash of redrawing + ;; everything on every event. + (def (compute-dirty-regions state) + (let ([ht (tui-state-last-drawn state)] + [dirty '()]) + (def (changed? key val) + (not (equal? (hash-get ht key) val))) + (def (add! flag) + (unless (memq flag dirty) + (set! dirty (cons flag dirty)))) + ;; First draw, mode change, or resize -> repaint everything. + (when (or (zero? (vector-length (hashtable-keys ht))) + (changed? 'mode (tui-state-mode state)) + (changed? 'width (tui-state-width state)) + (changed? 'height (tui-state-height state))) + (add! 'all)) + ;; Modal pages are simple and short-lived; redraw them whole. + (unless (eq? (tui-state-mode state) 'chat) + (add! 'all)) + (when (changed? 'input (tui-state-input state)) + (add! 'composer)) + (when (changed? 'status (tui-state-status state)) + (add! 'status)) + (when (changed? 'selected-index (tui-state-selected-index state)) + (add! 'conversations) + (add! 'thread) + (add! 'details)) + (when (changed? 'thread-scroll (tui-state-thread-scroll state)) + (add! 'thread)) + (when (changed? 'conv-scroll (tui-state-conv-scroll state)) + (add! 'conversations)) + (let ([conv (selected-conversation state)]) + (when (changed? 'conversation-count (length (tui-state-conversations state))) + (add! 'conversations)) + (when (changed? 'selected-conversation-id (and conv (conversation-id conv))) + (add! 'conversations) + (add! 'thread) + (add! 'details)) + (when (changed? 'selected-conversation-title (and conv (conversation-title conv))) + (add! 'conversations) + (add! 'thread)) + (when (changed? 'selected-message-count (and conv (length (conversation-messages conv)))) + (add! 'thread) + (add! 'conversations)) + (when (changed? 'selected-typing (and conv (conversation-typing conv))) + (add! 'thread))) + (when (changed? 'event-count (tui-state-event-count state)) + (add! 'details)) + (when (changed? 'mention-active? (tui-state-mention-active? state)) + (add! 'mention)) + (when (changed? 'mention-index (tui-state-mention-index state)) + (add! 'mention)) + (when (changed? 'mention-candidates-count (length (tui-state-mention-candidates state))) + (add! 'mention)) + dirty)) + + (def (update-last-drawn! state) + (let ([ht (tui-state-last-drawn state)] + [conv (selected-conversation state)]) + (hash-put! ht 'mode (tui-state-mode state)) + (hash-put! ht 'width (tui-state-width state)) + (hash-put! ht 'height (tui-state-height state)) + (hash-put! ht 'input (tui-state-input state)) + (hash-put! ht 'status (tui-state-status state)) + (hash-put! ht 'selected-index (tui-state-selected-index state)) + (hash-put! ht 'thread-scroll (tui-state-thread-scroll state)) + (hash-put! ht 'conv-scroll (tui-state-conv-scroll state)) + (hash-put! ht 'conversation-count (length (tui-state-conversations state))) + (hash-put! ht 'selected-conversation-id (and conv (conversation-id conv))) + (hash-put! ht 'selected-conversation-title (and conv (conversation-title conv))) + (hash-put! ht 'selected-message-count (and conv (length (conversation-messages conv)))) + (hash-put! ht 'selected-typing (and conv (conversation-typing conv))) + (hash-put! ht 'event-count (tui-state-event-count state)) + (hash-put! ht 'mention-active? (tui-state-mention-active? state)) + (hash-put! ht 'mention-index (tui-state-mention-index state)) + (hash-put! ht 'mention-candidates-count (length (tui-state-mention-candidates state))))) + (def (draw! state) (let* ([w (max 40 (tui-state-width state))] [h (max 12 (tui-state-height state))] @@ -2580,36 +2662,63 @@ [thread-w (max 10 (- w left-w details-w 2))] [details-x (- w details-w)] [body-h (max 4 (- h 4))] - [composer-y (- h 3)]) - (fill-rect! 0 0 w h (fg) (bg)) - - (cond - [(eq? (tui-state-mode state) 'new-message) - (draw-new-message-page! state 0 0 w status-y)] - [(eq? (tui-state-mode state) 'search) - (draw-search-page! state 0 0 w status-y)] - [(eq? (tui-state-mode state) 'attach) - (draw-attach-page! state 0 0 w status-y)] - [(eq? (tui-state-mode state) 'captcha) - (draw-captcha-page! state 0 0 w status-y)] - [(eq? (tui-state-mode state) 'theme) - (draw-theme-page! state 0 0 w status-y)] - [else - (draw-panel! 0 0 left-w body-h "Conversations") - (draw-panel! thread-x 0 thread-w body-h "Messages") - (when (> details-w 0) - (draw-panel! details-x 0 details-w body-h "Details")) - - (draw-conversations! state 1 2 (- left-w 2) (- body-h 3)) - (draw-thread! state (+ thread-x 1) 2 (- thread-w 2) (- body-h 3)) - (when (> details-w 0) - (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3))) - - (draw-composer! state 0 composer-y w) - (draw-mention-popup! state 0 composer-y w) - (when (eq? (tui-state-mode state) 'confirm-delete) - (draw-confirm-delete! state w h))]) - (draw-status! state 0 status-y w) + [composer-y (- h 3)] + [dirty (compute-dirty-regions state)]) + (when (pair? dirty) + (if (memq 'all dirty) + (begin + (fill-rect! 0 0 w h (fg) (bg)) + (cond + [(eq? (tui-state-mode state) 'new-message) + (draw-new-message-page! state 0 0 w status-y)] + [(eq? (tui-state-mode state) 'search) + (draw-search-page! state 0 0 w status-y)] + [(eq? (tui-state-mode state) 'attach) + (draw-attach-page! state 0 0 w status-y)] + [(eq? (tui-state-mode state) 'captcha) + (draw-captcha-page! state 0 0 w status-y)] + [(eq? (tui-state-mode state) 'theme) + (draw-theme-page! state 0 0 w status-y)] + [else + (draw-panel! 0 0 left-w body-h "Conversations") + (draw-panel! thread-x 0 thread-w body-h "Messages") + (when (> details-w 0) + (draw-panel! details-x 0 details-w body-h "Details")) + + (draw-conversations! state 1 2 (- left-w 2) (- body-h 3)) + (draw-thread! state (+ thread-x 1) 2 (- thread-w 2) (- body-h 3)) + (when (> details-w 0) + (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3))) + + (draw-composer! state 0 composer-y w) + (draw-mention-popup! state 0 composer-y w) + (when (eq? (tui-state-mode state) 'confirm-delete) + (draw-confirm-delete! state w h))]) + (draw-status! state 0 status-y w)) + (begin + ;; Partial repaint: only touch the regions that changed. + (when (memq 'composer dirty) + (draw-composer! state 0 composer-y w)) + (when (memq 'status dirty) + (draw-status! state 0 status-y w)) + (when (memq 'conversations dirty) + (draw-panel! 0 0 left-w body-h "Conversations") + (draw-conversations! state 1 2 (- left-w 2) (- body-h 3))) + (when (memq 'thread dirty) + (draw-panel! thread-x 0 thread-w body-h "Messages") + (draw-thread! state (+ thread-x 1) 2 (- thread-w 2) (- body-h 3))) + (when (and (> details-w 0) (memq 'details dirty)) + (draw-panel! details-x 0 details-w body-h "Details") + (draw-details! state (+ details-x 1) 2 (- details-w 2) (- body-h 3))) + ;; Mentions float over the thread panel; redraw them when either + ;; the mention state or the underlying thread changed. + (when (or (memq 'mention dirty) + (and (tui-state-mention-active? state) + (memq 'thread dirty))) + (draw-mention-popup! state 0 composer-y w)) + (when (eq? (tui-state-mode state) 'confirm-delete) + (draw-confirm-delete! state w h)))) + (update-last-drawn! state)) (draw-cursor! state w composer-y))) (def (draw-confirm-delete! state w h)