diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6185985..b2ca268 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ in a major one.
## [Unreleased]
-## [1.0.0] - 2026-09-07
+## [1.0.0] - 2026-09-08
First stable release. The public API is settled; see the versioning note above.
@@ -64,6 +64,12 @@ First stable release. The public API is settled; see the versioning note above.
### Fixed
+- Menu tracking is per-thread rather than per-process. `TrackPopupMenuEx` runs its nested loop
+ on the calling thread and the `HMENU` it displays belongs to that thread, but the guard that
+ stops a menu being rebuilt while it is on screen used a process-wide counter. WinForms allows
+ more than one UI thread, each with its own pump, so a popup open on one of them made
+ `Rebuild` throw on another that had no menu open at all. Found by a test run that failed on
+ one machine and passed on another, which is what a race looks like.
- `NativeListView.BackColor` and `ForeColor` did nothing. The list window paints itself, and
nothing was ever pushed across; they are now applied with `LVM_SETBKCOLOR`,
`LVM_SETTEXTBKCOLOR` and `LVM_SETTEXTCOLOR`. Both default to the window colors rather than
diff --git a/samples/NoteBook/LISTENING.md b/samples/NoteBook/LISTENING.md
index aed8517..8e2d009 100644
--- a/samples/NoteBook/LISTENING.md
+++ b/samples/NoteBook/LISTENING.md
@@ -114,6 +114,10 @@ very little.
two items answering to one key — which is the point of that check.
- Tab into the list. Confirm the header, the row text and the column order all read
right to left, and that arrow keys still move between rows rather than out of the list.
+- Tab to the **categories tree** and try to expand a node. **Left arrow** should open it and
+ **Right arrow** should close it — the reverse of the English layout, because a mirrored tree
+ draws its children to the left and the key that opens a node is the one pointing at them. If
+ Right still opens the node, the tree is right-aligning its text without actually mirroring.
- Switch back to **English** the same way and confirm everything returns.
Worth doing this on a machine whose Windows display language is Hebrew as well as on an
diff --git a/samples/NoteBook/MainForm.cs b/samples/NoteBook/MainForm.cs
index 79dc040..1b1b275 100644
--- a/samples/NoteBook/MainForm.cs
+++ b/samples/NoteBook/MainForm.cs
@@ -162,6 +162,14 @@ private void ApplyLanguage() {
RightToLeft = direction;
RightToLeftLayout = Strings.IsRightToLeft;
+ // RightToLeft is ambient and reaches every child on its own, but RightToLeftLayout is
+ // not: it has to be set on each control that has one, or the control right-aligns its
+ // text without ever mirroring. On a tree that is not cosmetic. An unmirrored tree keeps
+ // its left-to-right key bindings, so Right arrow expands a node while the node's
+ // children are drawn to the left - the arrow key and the layout disagree, and a
+ // keyboard user is the one who pays. Mirrored, Left expands, as Windows does in Hebrew.
+ _categories.RightToLeftLayout = Strings.IsRightToLeft;
+
_categoriesLabel.Text = Strings.Get("label.categories");
_notesLabel.Text = Strings.Get("label.notes");
_editorLabel.Text = Strings.Get("label.editor");
diff --git a/src/Oire.WinForms.NativeControls/MenuTrackingScope.cs b/src/Oire.WinForms.NativeControls/MenuTrackingScope.cs
index df1f0df..c811da5 100644
--- a/src/Oire.WinForms.NativeControls/MenuTrackingScope.cs
+++ b/src/Oire.WinForms.NativeControls/MenuTrackingScope.cs
@@ -6,12 +6,20 @@ namespace Oire.WinForms.NativeControls;
/// to destroy an HMENU Windows is still displaying.
///
///
-/// UI-thread only by design. TrackPopupMenuEx runs a nested message loop on the calling
-/// thread, and every caller here is on the WinForms UI thread, so the pump serializes access
-/// and no locking is needed. The counter is nesting-aware because a popup can open a submenu
-/// popup of its own.
+/// Per-thread, because a menu is. TrackPopupMenuEx runs a nested message loop on the
+/// calling thread and the HMENU it displays belongs to that thread, so a popup being
+/// tracked on one UI thread says nothing about whether another thread may rebuild its own menus.
+/// WinForms permits more than one UI thread, each with its own pump, and a process-wide counter
+/// would have one of them refusing a legitimate rebuild because an unrelated thread happened to
+/// have a menu open.
+///
+/// Thread-static also removes the need for locking: each thread sees only its own depth, and a
+/// thread's own message pump serializes its access to it. The counter is nesting-aware because
+/// a popup can open a submenu popup of its own.
+///
///
internal static class MenuTrackingScope {
+ [ThreadStatic]
private static int _depth;
/// True while at least one popup is being tracked.
diff --git a/tests/Oire.WinForms.NativeControls.Tests/MenuRebuildTests.cs b/tests/Oire.WinForms.NativeControls.Tests/MenuRebuildTests.cs
index ea8b67e..618df48 100644
--- a/tests/Oire.WinForms.NativeControls.Tests/MenuRebuildTests.cs
+++ b/tests/Oire.WinForms.NativeControls.Tests/MenuRebuildTests.cs
@@ -91,5 +91,56 @@ public void Rebuild_KeepsTheContextMenuAttachedToItsControl() {
});
}
+ ///
+ /// A popup being tracked on one thread must not stop another thread rebuilding its own
+ /// menus. A menu belongs to the thread that created it, so the tracking depth does too.
+ ///
+ ///
+ /// This is a regression guard for a real CI failure. The depth used to be a plain static,
+ /// and since every test here runs on its own thread and xUnit runs test classes in
+ /// parallel, one class holding a tracking scope made an unrelated rebuild in another class
+ /// throw "Cannot rebuild the menu bar while a popup menu is open". It failed on master
+ /// while passing on the branch, which is what a race looks like.
+ ///
+ [Fact]
+ public void Tracking_OnOneThread_DoesNotBlockAnother() {
+ using var entered = new ManualResetEventSlim();
+ using var mayLeave = new ManualResetEventSlim();
+
+ var trackingThread = new Thread(() => {
+ using (MenuTrackingScope.Enter()) {
+ MenuTrackingScope.IsTracking.Should().BeTrue("this thread is the one tracking");
+ entered.Set();
+ mayLeave.Wait(TimeSpan.FromSeconds(10));
+ }
+ });
+
+ trackingThread.SetApartmentState(ApartmentState.STA);
+ trackingThread.IsBackground = true;
+ trackingThread.Start();
+ entered.Wait(TimeSpan.FromSeconds(10)).Should().BeTrue("the other thread must get going");
+
+ try {
+ // Same moment, different thread: this one has no menu open and must be free to act.
+ MenuTrackingScope.IsTracking.Should().BeFalse("another thread's popup is not ours");
+
+ StaRunner.Run(() => {
+ using var form = new Form();
+ _ = form.Handle;
+
+ using var bar = new NativeMenuBar(form);
+ bar.Attach(new NativeMenuSpec().AddMenu("&File", file => file.Add("&New", NoOp)));
+
+ var rebuild = () => bar.Rebuild(
+ new NativeMenuSpec().AddMenu("&Datei", file => file.Add("&Neu", NoOp)));
+
+ rebuild.Should().NotThrow("no popup is open on this thread");
+ });
+ } finally {
+ mayLeave.Set();
+ trackingThread.Join(TimeSpan.FromSeconds(10));
+ }
+ }
+
private static void NoOp() { }
}