[GH-ISSUE #119] ui/syntax/syntaxedit.go: characters/lines don't render while editing (typing, delete-selection) until manual redraw trigger #79

Open
opened 2026-10-13 21:11:39 +00:00 by bsakaguchi · 0 comments
Owner

Originally created by @fr-nt on GitHub (Aug 12, 2026).
Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-windows-client/issues/119

In the tunnel edit dialog, the raw config text editor (ui/syntax/syntaxedit.go)
has a rendering bug where the RichEdit control doesn't visually update after
certain text edits, even though the underlying text buffer is correct.

Reproduced symptoms:

  1. Typing: newly typed characters don't appear on screen until you click
    elsewhere in the field or scroll.
  2. Select + delete: deleting a selection (e.g. removing an address/peer
    line) can leave the deleted line(s) visually on screen until the field is
    interacted with again.
  3. Resizing the application window always makes the stale content refresh
    correctly, which was the key clue for the root cause below.

This reproduces on both AmneziaWG configs (many [Interface]/[Peer]
parameters, e.g. Jc/Jmin/Jmax/S1-S4/H1-H4/I1-I5) and appears to be present in
upstream wireguard-windows' identical code as well (unchanged there since
~2019-2021), though it's more noticeable here likely because AmneziaWG configs
produce more highlighter spans per highlightText() pass.

Root cause

highlightText() in ui/syntax/syntaxedit.go re-colors the entire document
on every EN_CHANGE, wrapped in WM_SETREDRAW FALSE / TRUE to avoid
flicker while it walks every highlight span and calls EM_SETCHARFORMAT.
After re-enabling redraw and issuing a plain RedrawWindow(...), RichEdit
sometimes leaves stale glyph state for the region that was actually just
edited — a plain repaint invalidates the painted area but doesn't force
RichEdit to recompute its internal line/wrap layout. Manually resizing the
window works because a resize forces that internal layout recalculation,
which a repaint alone does not trigger.

Fix

Force that same internal layout recalculation programmatically (without an
actual visual resize) by re-sending the control its own current client rect
via EM_SETRECT right after re-enabling redraw, before restoring
scroll/selection. Also restore scroll via EM_SETSCROLLPOS as before (needed
to cancel scroll drift accumulated while the formatting loop moves the
selection across the whole document), followed by an EM_SCROLLCARET nudge
in case the edit itself moved the caret out of view.

Diff (against the current ui/syntax/syntaxedit.go), applied at the end of
highlightText():

​```diff

  • win.SendMessage(hWnd, win.EM_SETSCROLLPOS, 0, uintptr(unsafe.Pointer(&origScroll)))
    win.SendMessage(hWnd, win.EM_EXSETSEL, 0, uintptr(unsafe.Pointer(&origSelection)))
    win.SendMessage(hWnd, win.EM_HIDESELECTION, win.FALSE, 0)
    win.SendMessage(hWnd, win.WM_SETREDRAW, win.TRUE, 0)
  • // Force RichEdit to fully recompute its internal line/wrap layout, not
  • // just repaint. A plain repaint (RedrawWindow) leaves freshly typed
  • // characters, or lines removed via a selection+delete, stale on
  • // screen; resizing the window fixes both, because resizing forces
  • // this same internal recalculation. Re-sending the control its own
  • // current client rect triggers that recalculation without actually
  • // changing the window's size on screen. This must happen before the
  • // scroll/caret restore below, not after, or the layout recalculation
  • // can itself shift the caret's visual position.
  • var clientRect win.RECT
  • win.GetClientRect(hWnd, &clientRect)
  • win.SendMessage(hWnd, win.EM_SETRECT, 0, uintptr(unsafe.Pointer(&clientRect)))
  • // Restore the scroll position captured before the loop, to cancel out
  • // any scroll drift accumulated while EM_EXSETSEL walked through every
  • // span during formatting. Then nudge with EM_SCROLLCARET in case the
  • // edit itself genuinely moved the caret out of view — this only acts
  • // when needed and does not fight the restored scroll position above.
  • win.SendMessage(hWnd, win.EM_SETSCROLLPOS, 0, uintptr(unsafe.Pointer(&origScroll)))
  • win.SendMessage(hWnd, win.EM_SCROLLCARET, 0, 0)
    win.RedrawWindow(hWnd, nil, 0, win.RDW_ERASE|win.RDW_FRAME|win.RDW_INVALIDATE|win.RDW_ALLCHILDREN)
    win.SendMessage(hWnd, win.EM_SETEVENTMASK, 0, win.ENM_CHANGE)
    se.idoc.Undo(win.TomResume, nil)
    ​```
Originally created by @fr-nt on GitHub (Aug 12, 2026). Original GitHub issue: https://github.com/amnezia-vpn/amneziawg-windows-client/issues/119 In the tunnel edit dialog, the raw config text editor (`ui/syntax/syntaxedit.go`) has a rendering bug where the RichEdit control doesn't visually update after certain text edits, even though the underlying text buffer is correct. Reproduced symptoms: 1. **Typing**: newly typed characters don't appear on screen until you click elsewhere in the field or scroll. 2. **Select + delete**: deleting a selection (e.g. removing an address/peer line) can leave the deleted line(s) visually on screen until the field is interacted with again. 3. Resizing the application window always makes the stale content refresh correctly, which was the key clue for the root cause below. This reproduces on both AmneziaWG configs (many `[Interface]`/`[Peer]` parameters, e.g. Jc/Jmin/Jmax/S1-S4/H1-H4/I1-I5) and appears to be present in upstream `wireguard-windows`' identical code as well (unchanged there since ~2019-2021), though it's more noticeable here likely because AmneziaWG configs produce more highlighter spans per `highlightText()` pass. ### Root cause `highlightText()` in `ui/syntax/syntaxedit.go` re-colors the *entire* document on every `EN_CHANGE`, wrapped in `WM_SETREDRAW FALSE` / `TRUE` to avoid flicker while it walks every highlight span and calls `EM_SETCHARFORMAT`. After re-enabling redraw and issuing a plain `RedrawWindow(...)`, RichEdit sometimes leaves stale glyph state for the region that was actually just edited — a plain repaint invalidates the *painted* area but doesn't force RichEdit to recompute its *internal line/wrap layout*. Manually resizing the window works because a resize forces that internal layout recalculation, which a repaint alone does not trigger. ### Fix Force that same internal layout recalculation programmatically (without an actual visual resize) by re-sending the control its own current client rect via `EM_SETRECT` right after re-enabling redraw, before restoring scroll/selection. Also restore scroll via `EM_SETSCROLLPOS` as before (needed to cancel scroll drift accumulated while the formatting loop moves the selection across the whole document), followed by an `EM_SCROLLCARET` nudge in case the edit itself moved the caret out of view. Diff (against the current `ui/syntax/syntaxedit.go`), applied at the end of `highlightText()`: ​```diff - win.SendMessage(hWnd, win.EM_SETSCROLLPOS, 0, uintptr(unsafe.Pointer(&origScroll))) win.SendMessage(hWnd, win.EM_EXSETSEL, 0, uintptr(unsafe.Pointer(&origSelection))) win.SendMessage(hWnd, win.EM_HIDESELECTION, win.FALSE, 0) win.SendMessage(hWnd, win.WM_SETREDRAW, win.TRUE, 0) + // Force RichEdit to fully recompute its internal line/wrap layout, not + // just repaint. A plain repaint (RedrawWindow) leaves freshly typed + // characters, or lines removed via a selection+delete, stale on + // screen; resizing the window fixes both, because resizing forces + // this same internal recalculation. Re-sending the control its own + // current client rect triggers that recalculation without actually + // changing the window's size on screen. This must happen before the + // scroll/caret restore below, not after, or the layout recalculation + // can itself shift the caret's visual position. + var clientRect win.RECT + win.GetClientRect(hWnd, &clientRect) + win.SendMessage(hWnd, win.EM_SETRECT, 0, uintptr(unsafe.Pointer(&clientRect))) + // Restore the scroll position captured before the loop, to cancel out + // any scroll drift accumulated while EM_EXSETSEL walked through every + // span during formatting. Then nudge with EM_SCROLLCARET in case the + // edit itself genuinely moved the caret out of view — this only acts + // when needed and does not fight the restored scroll position above. + win.SendMessage(hWnd, win.EM_SETSCROLLPOS, 0, uintptr(unsafe.Pointer(&origScroll))) + win.SendMessage(hWnd, win.EM_SCROLLCARET, 0, 0) win.RedrawWindow(hWnd, nil, 0, win.RDW_ERASE|win.RDW_FRAME|win.RDW_INVALIDATE|win.RDW_ALLCHILDREN) win.SendMessage(hWnd, win.EM_SETEVENTMASK, 0, win.ENM_CHANGE) se.idoc.Undo(win.TomResume, nil) ​```
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
amnezia-vpn/amneziawg-windows-client#79
No description provided.