Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions samples/NoteBook/LISTENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions samples/NoteBook/MainForm.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
16 changes: 12 additions & 4 deletions src/Oire.WinForms.NativeControls/MenuTrackingScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,20 @@ namespace Oire.WinForms.NativeControls;
/// to destroy an <c>HMENU</c> Windows is still displaying.
/// </summary>
/// <remarks>
/// UI-thread only by design. <c>TrackPopupMenuEx</c> 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. <c>TrackPopupMenuEx</c> runs a nested message loop on the
/// calling thread and the <c>HMENU</c> 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
internal static class MenuTrackingScope {
[ThreadStatic]
private static int _depth;

/// <summary>True while at least one popup is being tracked.</summary>
Expand Down
51 changes: 51 additions & 0 deletions tests/Oire.WinForms.NativeControls.Tests/MenuRebuildTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,56 @@ public void Rebuild_KeepsTheContextMenuAttachedToItsControl() {
});
}

/// <summary>
/// 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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() { }
}