dotnet / dotnet/winforms

`RichTextBox` reports line positions for text removed while the form was minimized, and paints garbled rows after the restore

Open
#14,830 2 comments 2 reactions 0 assignees View on GitHub
untriaged
Dominant language
C#
Stars
4.9k
Forks
1.1k
Avg merge
1d 13m
Merged PRs (30d)
85

Description

### .NET version

10

### Did it work in .NET Framework?

No

### Did it work in any of the earlier releases of .NET Core or .NET 5+?

| Framework | Result |
|---|---|
| .NET 10.0.10 (`net10.0-windows`), x64 | reproduced |
| .NET 8.0.28 (`net8.0-windows`), x64 | reproduced |
| .NET Framework 4.8.9325.0 (`net48`), x64 | reproduced |

### Issue description

If text is **removed** from a `RichTextBox` while the owning `Form` is **minimized**, the control goes on reporting the line positions the text had *before* the removal, and it paints its rows at those positions once the form is restored. The result is visibly garbled: log entries run together and line breaks fall in the middle of words.

`RichTextBox.Text` is **completely intact** the whole time. Only the control's idea of where its lines start is wrong, so nothing an application can check about its own text will detect this.

The control's own public API contradicts itself at that point:

```
box.TextLength = 1365
box.Text really contains = 40 lines
box.GetFirstCharIndexFromLine(1) = 15 <-- inside line 0, not a line start
box.GetFirstCharIndexFromLine(40) = 1380 <-- 15 characters past the end of Text
box.GetFirstCharIndexFromLine(41) = -1 <-- so the control believes it has 41 lines
box.GetLineFromCharIndex(1364) = 39 <-- while its own line lookup says the text ends on line 39
```

Restoring the form does not correct it, and neither does repainting or continued appending. Only a genuine size change afterwards does — which is why, to a user, the window appears to "heal itself" the moment they drag its edge.

**Insertions made in the same state are tracked correctly.** Only removals are lost.

### Why this is filed against WinForms

The underlying defect looks like it lives in RichEdit (`msftedit.dll`) — we also have a ~230-line pure-Win32 reproduction with no .NET involved, as reported [here](https://aka.ms/AA12ohyr); I can attach it here if feedback hub is too annoying. The bug is filed here because WinForms is what makes ordinary applications hit it:

- **WinForms drives the control into the state that breaks it, by default.** Minimizing the form makes WinForms' layout resize a `Dock`ed or `Anchor`ed child to the minimized client size (`0x0` — step 2 of the output below prints it) and back again on restore. That resize-to-nothing and back is a necessary part of the failure: with `--nodock`, where the control's size is never touched, the same edit is handled correctly. A native application chooses whether to resize its child while iconic; a WinForms application with `Dock = Fill` does not.
- **The damage surfaces through public WinForms API.** `GetFirstCharIndexFromLine` returns an index 15 characters past the end of `Text`. The reproduction below reads it back with nothing but that method — no `SendMessage`, no `EM_LINEINDEX`.
- **It reaches a widely used library.** `NLog.Windows.Forms`' `RichTextBoxTarget.MaxLines` trimming performs exactly this removal, so any NLog-backed WinForms log window is exposed whenever it is minimized.

### Steps to reproduce

Attached: [StaleLines.cs.txt](https://github.com/user-attachments/files/30567954/StaleLines.cs.txt) + [StaleLines.csproj.txt](https://github.com/user-attachments/files/30567955/StaleLines.csproj.txt) (both renamed to allow upload here)

```bash
dotnet run --project StaleLines.csproj
```

Exit code **1** = reproduced, **0** = the control stayed consistent, **2** = `Text` itself was damaged (it never is), **9** = harness failure. Use `--nopause` for an unattended run.

The program:

1. creates a `Form` with a `RichTextBox` at `Dock = Fill`, `ReadOnly = true`, `WordWrap = false` — an ordinary log view. Wrapping is off so that one line of `Text` must be exactly one row, which is what makes the check below exact;
2. appends 40 lines with `AppendText`, **deliberately of differing lengths** so that a wrong line position shows up as split words rather than coincidentally landing on a real line start;
3. sets `WindowState = Minimized`;
4. removes the first line: `Select(0, n)` then `SelectedRtf = "{\rtf1\ansi}"`, with `n` read from `Text` rather than assumed;
5. sets `WindowState = Normal`;
6. after each step, compares `Text` against every line position the control reports. With wrapping off, `GetFirstCharIndexFromLine(i)` must be 0 or land immediately after a `'\n'` in `Text`.

It runs on a real message loop (`Application.Run`, awaiting `Task.Delay` between steps) — no `DoEvents`, so the minimize and restore are processed normally.

#### Expected behaviour

After step 5 the control holds 1365 characters and 40 lines, so it should report 40 lines and every `GetFirstCharIndexFromLine(i)` should land on a line boundary — which is what step 1 shows it doing correctly before the minimize, and what step 5 shows it doing again after any real size change.

#### Actual behaviour

The control reports 41 lines, one line position is 15 characters past the end of `Text`, and 35 of the 41 land in the middle of a line. It paints accordingly.

```
.NET 10.0.10, x64
RichTextBox line positions vs Text (deleting 1 line, removed with SelectedRtf = "{\rtf1\ansi}")

1. after 40 appends control 820x470 Text: 1380 chars, 41 lines | control reports 41 lines, 0 start mid-line, 0 start past the end of Text
2. minimized control 0x0 Text: 1380 chars, 41 lines | control reports 41 lines, 0 start mid-line, 0 start past the end of Text
3. still minimized, deleted control 0x0 Text: 1365 chars, 40 lines | control reports 41 lines, 35 start mid-line, 1 start past the end of Text
first offender: GetFirstCharIndexFromLine(1) = 15 <-- not a line start
4. restored control 820x470 Text: 1365 chars, 40 lines | control reports 41 lines, 35 start mid-line, 1 start past the end of Text
first offender: GetFirstCharIndexFromLine(1) = 15 <-- not a line start

Public API on that control, all at the same moment:
box.TextLength = 1365
box.Text really contains = 40 lines
box.GetFirstCharIndexFromLine(1) = 15 <-- inside line 0, not a line start
box.GetFirstCharIndexFromLine(40) = 1380 <-- 15 characters past the end of Text
box.GetFirstCharIndexFromLine(41) = -1 <-- so the control believes it has 41 lines
box.GetLineFromCharIndex(1364) = 39 <-- while its own line lookup says the text ends on line 39

The control reports these line positions, so this is what it paints:
row 0 |line 01: the qu|
row 1 |ick brown fox jumps over the lazy dog\nline 02: openin| <-- does not start on a line boundary
row 2 |g\nline 03: collec| <-- does not start on a line boundary
row 3 |ting frames\nline 04: a much| <-- does not start on a line boundary
row 4 | longer line of text so the rows are clearly of different lengths\nline 05: done\nl| <-- does not start on a line boundary
row 5 |ine 06: moving| <-- does not start on a line boundary
row 6 | to the next position and waiting for it to settle\nline 07: idle\nl| <-- does not start on a line boundary
row 7 |ine 08: reticu| <-- does not start on a line boundary

5. after a 1px form resize control 820x470 Text: 1365 chars, 40 lines | control reports 40 lines, 0 start mid-line, 0 start past the end of Text

The control reports these line positions, so this is what it paints:
row 0 |line 01: the quick brown fox jumps over the lazy dog|
row 1 |line 02: opening|
row 2 |line 03: collecting frames|
row 3 |line 04: a much longer line of text so the rows are clearly of different lengths|
row 4 |line 05: done|
row 5 |line 06: moving to the next position and waiting for it to settle|
row 6 |line 07: idle|
row 7 |line 08: reticulating splines|

FAIL: after the restore the control still described the deleted text.
```

Those "as painted" rows are not a reconstruction: they are the current `Text` cut at the positions the control reports, and they match the window on screen character for character (`garbled-window.png`; `healed-window.png` is the same control one 1px form resize later). `\n` marks a line break falling *inside* a row — two entries painted as one.

Garbled window

Healed window

### What narrows it down

Every row below is from the attached program on .NET 10; `--insert`, `--anchor` and `--nodock` were each also confirmed on .NET 8 and .NET Framework 4.8.

| Variant | Result | What it shows |
|---|---|---|
| *(default)* removes one whole line | **fails** | |
| `--chars` removes 5 characters from *inside* a line | **fails** | No line boundary is touched, so the line count does not even change — yet 39 of 41 positions end up 5 characters off |
| `--insert` inserts 5 lines in the same state | passes | Specific to removal; insertions are tracked |
| `--anchor` sizes the control by `Anchor` instead of `Dock` | **fails** | Not a `Dock` peculiarity — any control WinForms resizes with the form is exposed |
| `--nodock` control keeps its size across the minimize | passes | The resize to the minimized client size and back is necessary |
| `--api=rtf` (default) `SelectedRtf = "{\rtf1\ansi}"` | **fails** | |
| `--api=text` `SelectedText = ""` with `ReadOnly` lifted | **fails** | |
| `--api=clear` `Clear()` then `AppendText(remainder)` | **fails** | |
| `--api=settext` `Text = remainder` | **fails** | |

The four removal APIs fail *identically* — same character counts, same 35 bad positions — so this is below the public API surface, not a quirk of one of them.

From earlier narrowing with a larger harness (not attached, available on request):

- **Two lines and one removal are enough**; document size is irrelevant.
- The removed range may be anywhere — first, last or middle line, on screen or scrolled out.
- `Hide()` does **not** trigger it, and neither does resizing the control to `0x0` while the form stays visible. It specifically needs the *form* to be minimized across the removal.

### Workarounds we tried

While still minimized, immediately after the removal:

| Attempt | Effect |
|---|---|
| `Invalidate()` + `Update()`, `WM_SETREDRAW` off/on, `EM_SCROLLCARET`, re-querying the line positions | none — the stale values persist |
| `SetWindowPos` on the control's handle | none — size changes appear to be ignored while the form is iconic |
| `EM_SETTARGETDEVICE(NULL, 0)` | corrects it, but a line width of 0 means "wrap to window", so it silently turns `WordWrap` back **on**. This is the same message WinForms uses to *implement* `WordWrap = false` (with width 1), and with width 1 it corrects nothing |
| `EM_SETRECT` with the client rect or the last-visible rect | corrects it, but the rectangle becomes the control's explicit formatting rectangle and survives the restore, after which the control paints almost nothing |
| `EM_SETRECT` with a `NULL` rect | none |

After the restore:

- **A real size change on the control's window corrects it reliably.** This is what we ship. Note that `richTextBox.Width += 1` on a docked control is *not* such a change — WinForms' layout restores the docked bounds before RichEdit ever sees a resize, which cost us a release. It has to be either a size change on the form (what the attached program does at step 5) or `SetWindowPos` on `richTextBox.Handle` directly:

```csharp
GetClientRect(box.Handle, out RECT rc);
int w = rc.Right - rc.Left, h = rc.Bottom - rc.Top;
SetWindowPos(box.Handle, IntPtr.Zero, 0, 0, w + 1, h, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
SetWindowPos(box.Handle, IntPtr.Zero, 0, 0, w, h, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE);
```

Every application with a `RichTextBox` log or transcript view that trims itself needs this, and there is nothing to tell them so.

### Impact

We hit this in a production industrial inspection application. The log window renders garbled after the application has been minimized, operators cannot read it until they resize the window, and because `Text` is intact there is nothing in the application that can notice. It took a long investigation to establish that the log data was never actually corrupt.

### Environment

- **OS:** Windows 11 Pro, build 10.0.26200, x64
- **`msftedit.dll`:** `C:\Windows\System32\msftedit.dll`, 3,284,992 bytes, SHA256 `DEA2655F6F7F7CD4A89BAC75ECE47056761010F975F9B8B9CB8CFAFF8286789C`. Reports version 10.0.26100.8521 when queried at a copied path; the same bytes report 10.0.26100.8875 at the `System32` path (the version APIs merge in the MUI resource there) — please trust the SHA256
- **Process:** x64. A 32-bit process loads SysWOW64's `msftedit.dll`, a different binary; the attached project sets `PlatformTarget=x64` for that reason
- **Display:** single 2560×1440, 96 DPI. Reproduces both DPI-unaware (the attached project, which declares nothing) and PerMonitorV2 (our earlier harness), so DPI awareness does not appear to matter
- The occurrences that led us here were on a second machine on build 26100 with `msftedit.dll` 10.0.26100.7705.

Contributor guide

Open the contributing guide

Research direction

Start by running the attached StaleLines.cs.txt and StaleLines.csproj reproduction with `dotnet run`, then inspect the RichTextBox minimize, restore, and layout-resize behavior described in the issue. Done means removing text while the form is minimized leaves the control's line-count and line-index APIs consistent after restore, without requiring a later manual resize.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
desktop
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.