Eto.Forms cross-platform UI toolkit. Keep this file compact — it loads every session. Record only durable, non-obvious Eto facts; skip anything obvious from code/git.
AGENTS.md admission rule: add a fact only when it is broadly useful across future tasks, stable, genuinely difficult or costly to rediscover, and not better captured by code, tests, comments, or focused documentation. Do not record machine-specific state, one-off failures, routine implementation details, or facts already evident from the repository. When in doubt, do not add it. Curate existing entries and remove stale or low-value notes to justify the token cost this file imposes on every request.
dotnet test --project test/Eto.Test.UnitTests/Eto.Test.UnitTests.csproj -f net10.0 --filter "BrushTests"--filteruses NUnit / Microsoft.Testing.Platform syntax; a bare class name ("BrushTests") orFullyQualifiedName~Brushboth work. Omit--filterto run everything.- Always exclude the
ManualTestcategory — on every run, not just unscoped ones. They put a window on the user's screen and block indefinitely (ManualFormwaits withtimeout: -1) until a human performs the interaction and clicks Pass/Fail — several are slow and fiddly to do by hand. Running them unasked hijacks the user's machine, and whatever they report is about whether the person carried out the steps, not whether the code works: they surface as failures, not skips, so they masquerade as real breakage and poison a before/after comparison.--filter "FullyQualifiedName~Grid&TestCategory!=ManualTest", or--filter "TestCategory!=ManualTest"to run everything else. - Repeat the exclusion in every
|alternative.&binds tighter than|, soFullyQualifiedName~CheckBox|FullyQualifiedName~RadioButton&TestCategory!=ManualTeststill runs every manualCheckBoxtest. Parentheses would group it, but the macos TFM parses its own filter and treats them as literal characters — silently matching nothing — so repeat the term instead:"FullyQualifiedName~CheckBox&TestCategory!=ManualTest|FullyQualifiedName~RadioButton&TestCategory!=ManualTest". - Always pass
-f— the project multi-targetsnet48;net10.0;net10.0-windows(plusnet10.0-macoson a Mac). On Linux use-f net10.0; the Windows-only TFMs won't build there. - Test runner is Microsoft.Testing.Platform (set in
global.json), NUnit 4. - The modern Mac-specific tests are in
test/Eto.Test.Mac/Eto.Test.macOS.csprojand require the matching .NET macOS workload. The presence ofMicrosoft.macOS.Refpacks alone is not sufficient. If the workload rejects a newer Xcode patch version, set<ValidateXcodeVersion>false</ValidateXcodeVersion>(already set inbuild/Common.Build.props). - On a Mac,
-f net10.0-macosruns the tests against the modern .NET macOS backend (Eto.macOS/Eto.Test.macOS),-f net10.0against MonoMac (Eto.Mac64). The macos TFM builds the runner as a real.appand has to: the bundle's native launcher is what initializes ObjCRuntime, so a bundle-less build (_CanOutputAppBundle=false) produces an executable that dies inRuntime.EnsureInitializedbefore reachingMain.RunWithOpen=falsemakesdotnet testrun the executable inside the bundle rather thanopening the app, which would detach it from the test host. - NUnit3TestAdapter's testing-platform bridge can't run tests from an app bundle, so the macos TFM
doesn't use it: the NUnit engine's driver builds an
AssemblyDependencyResolverper test assembly, which needs hostpolicy to have been initialized bycorehost_main. The bundle's launcher starts the runtime itself, so every assembly fails to load with "Hostpolicy must be initialized ...".Eto.Test.UnitTests/NUnitTestFramework.csruns NUnit in-process there instead (sharingUnitTestRunnerwith the GUI app's Unit Tests section) and implements--filteritself — a subset:FullyQualifiedName/Name/TestCategorywith=!=~!~, combined with&and|, no parentheses. - A macos-TFM app killed at launch with no output at all (exit 137, "Code Signature Invalid" in
~/Library/Logs/DiagnosticReports) means its bundled runtime dylibs weren't re-signed: the SDK rewrites their install names, invalidating Microsoft's signature, and keeps the codesign stamps inartifacts/obj— so deleting the.appwithout the obj dir makes it re-copy them and skip signing. Deleteartifacts/obj/Mac/<project>and rebuild. - Reflection gotcha (net48 vs net):
Type.GetType("Ns.Type, PresentationCore")(partial assembly name) resolves on .NET but returns null on .NET Framework, so tests that reflect over WPF types (e.g. finding the nativeScrollViewer) silently no-op on net48. Search loaded assemblies instead:AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetType(fullName)).FirstOrDefault(t => t != null). - Windowed/GUI tests run without special setup on all platforms (Linux, Mac, Windows) as
long as a display is available — on Linux that's a real X display (e.g.
DISPLAY=:0; noxvfb-runneeded). Tests thatShow()/Close()windows (e.g.PreferredSizeShouldAccountForAllRowsWhenLargerThanWindow, anything viaShownAsync/Form/Paint) actually execute. - Wpf gotcha (fixed in
Eto.Test.UnitTests/Program.cs) — showing a themed control likeTreeGridViewunder the test host used to crash with a bogusFileLoadException: Could not load file or assembly 'Eto.Wpf.Aero2'(E_POINTER) →NullReferenceExceptioninNUnit.Engine.Internal.RuntimeLibrariesStrategy.TryToResolve. Laying out a themed Eto.Wpf control makes WPF probe an external per-OS-theme satellite assembly (Eto.Wpf.Aero2, etc.) before falling back to the embeddedthemes/generic.xaml; that satellite never exists and in the real app the probe just returns "not found", but the NUnit test host's assemblyResolvinghandler throws an NRE instead of returning null, surfacing as a fatal load error during layout. Fix:Program.Mainregisters anAssemblyLoadContext.Default.Resolvinghandler FIRST (before NUnit's) that throwsFileNotFoundExceptionforEto.*theme-satellite names (.Aero2,.Aero,.AeroLite,.Classic,.Luna,.Royale,.Generic) so WPF falls back cleanly and NUnit's broken handler never runs (#if NET— .NET-Core test host only). Was deterministic per grid type (GridView fine, TreeGridView crashed), observed on arm64. (Surfaced writing the b46b9827 scrollable-fill regression tests.)
When a test "passes" but the rendered output looks wrong (clipping, wrong size, misplaced
widgets), capture the actual pixels and look at them. There are usually no OS screenshot tools
(scrot, import, grim, …) available, so grab the window from the backend's own native API
inside a tiny standalone Eto app.
Cross-platform strategy (backend-agnostic):
- Write a small console
.csproj(net10.0,OutputType=Exe) that<Reference>s the built DLLs for the backend under test (see per-backend note below). Build the platform lib you're editing first, e.g.dotnet build src/Eto.Gtk/Eto.Gtk.csproj -f net10.0. - Build the form exactly as the test does,
Show()it, then in a timer/dispatch callback (after it has actually mapped/rendered) take the screenshot andapp.Quit(). - Run it, then
Readthe PNG (the Read tool renders images) to actually see the output. - Gotcha:
dotnet bin/.../app.dllloads its dependencies from that samebinoutput dir, not from wherever your<HintPath>points — after rebuilding a platform DLL you must copy it into the app'sbin/Debug/net10.0/for the change to take effect. - Toggle deep diagnostics from native code with an env var (e.g.
Environment.GetEnvironmentVariable("ETO_X") == "1"→Console.Error.WriteLine(...)) so you can dump internal measurements without editing call sites. Remove them before finishing.
The actual pixel-grab is backend-specific — reach form.ControlObject/.NativeHandle for the
native window and use that toolkit's capture API:
-
Gtk (verified — Linux/XWayland here; run with
DISPLAY=:0 GDK_BACKEND=x11):var native = form.ControlObject as Gtk.Widget; var gdkWin = native?.Window ?? (native?.Toplevel as Gtk.Window)?.Window; var pb = new Gdk.Pixbuf(gdkWin, 0, 0, gdkWin.Width, gdkWin.Height); // Gdk.Pixbuf.FromWindow pb.Save("/path/out.png", "png");
-
Mac (not yet tried here): render the
NSView/NSWindow—NSView.BitmapImageRepForCachingDisplaywithCacheDisplay, orCGWindowListCreateImagefor the whole window — then write viaNSBitmapImageRep.AsTiff()/PNG representation. -
Wpf (not yet tried):
RenderTargetBitmap.Render(visual)→PngBitmapEncoderto a file. -
WinForms (not yet tried):
Control.DrawToBitmap(bmp, rect), orGraphics.CopyFromScreenfor on-screen pixels →Bitmap.Save(..., ImageFormat.Png).
Only the Gtk path above has been exercised; treat the others as starting points to verify.
To exercise the real keypress→widget path (e.g. verifying a TextBox fires input events exactly
once), drive a shown window with xdotool over the real X display — gtk_test_widget_send_key
P/Invoke is a no-op here (needs a focused toplevel under a WM). Window management can't be relied
on (see the mouse section below — the session is mutter/XWayland), so:
- Launch the app (
DISPLAY=:0 GDK_BACKEND=x11), give the target widget focus, print aREADYmarker, and add aUITimerauto-quit so the process never hangs (nokill— it's forbidden). xdotoolisn't on the login-shell PATH here; call it as/usr/bin/xdotool.search --name <title>returns two window ids (client + GTK helper) — use the last one.windowactivatefailsBadWindowwithout a WM; usewindowfocus --sync <id>then inject with XTEST (xdotool key a/xdotool type "ab"— no--window, which uses XSendEvent that GTK ignores). XTEST goes to whatever holds X input focus, whichwindowfocusjust set.
xdotool pointer injection does not work here (only keyboard does): under a mutter/XWayland
session a full-screen mutter guard window sits above all X clients, so XTEST mousemove/
mousedown/click never reach the app (xdotool getmouselocation reports the root window even
when the pointer is over your window). Also xdotool search --name returns both the mutter frame
and the client window — the frame's geometry is offset from the client's, so clicking frame + n
lands on the decoration; check xwininfo -id <id> (Width/Height) to pick the client.
Instead synthesize GDK events and push them through GTK's real dispatch, which honours grabs
(gtk_grab_add from gtk_dialog_run, etc.), so capture/grab behaviour is exercised faithfully:
var ev = Gdk.EventHelper.New(Gdk.EventType.ButtonPress); // or MotionNotify/ButtonRelease
var bev = new Gdk.EventButton(ev.Handle) { Window = eventBoxGdkWindow, SendEvent = true,
Time = time += 100, X = 60, Y = 60, Button = 1, State = 0,
Device = Gdk.Display.Default.DefaultSeat.Pointer }; // XRoot/YRoot from Window.GetOrigin
Gtk.Main.DoEvent(ev);- Get the widget to target from the handler's
EventControl(anEtoEventBoxforPanel) and use its.Windowas the event window, otherwise GTK routes the event elsewhere. - A modal
Dialogblocks in a nested main loop inside your handler, so schedule each later step on its ownGLib.Timeoutsource — a single source won't re-enter while its callback is blocked. - Anything reading the real pointer (
Mouse.Position,Mouse.Buttons, and so the location and buttons of events Eto synthesizes itself) still reports the physical mouse, not your fake events.
Use Gtk.Global.PropagateEvent, not Gtk.Main.DoEvent, for synthesized key events. DoEvent
silently drops them once any earlier test in the process has shown and closed a window containing a
ComboBox (bisected to ComboBoxTests.SettingDataStoreToNullAfterPopulatedShouldNotCrash): the
toplevel's key-press-event never fires, while Gtk.Grab.Current, the window-group grab and the
device grab are all null and the window is active with toplevel focus — so it is not a leftover grab,
and it is invisible when the fixture runs alone. Gtk.Global.PropagateEvent(toplevel, ev) is immune
and is what GTK itself uses for keys (accelerators → focus widget → window bindings). DoEvent
remains correct for the mouse case above.
- Unit-test source
.csfiles live intest/Eto.Test/UnitTests/(compiled as part of the sharedEto.Testproject) — that's where you edit tests. - You run them via
test/Eto.Test.UnitTests/— a thin runner project (onlyProgram.cs) that referencesEto.Test.csproj. Don't look for test code there. - Backend-specific tests go in
test/Eto.Test.<Platform>/UnitTests/(e.g.test/Eto.Test.Mac/UnitTests/, namespaceEto.Test.Mac.UnitTests, deriving from the sharedEto.Test.UnitTests.TestBase). Put a test there rather than P/Invoking or reflecting your way to native APIs from the shared project — those projects reference the backend and its bindings directly (Eto.Mac.Messaging,ObjCExtensions, MonoMac types via global usings; noteMessagingis ambiguous withMonoMac.ObjCRuntime.Messaging, so qualify it asEto.Mac.Messaging). - They run both ways:
Eto.Test.UnitTestsreferences the platform test apps and itsProgram.GetTestAssemblies()yields each one (gated onPlatform.Instance.IsMac/IsGtk/…), sodotnet testpicks them up; and the Eto.Test GUI app's Unit Tests section runs them viaapp.TestAssemblies.Add(typeof(Startup).Assembly)in each platform'sStartup. Discovery is per-assembly, so a new fixture in an existingEto.Test.<Platform>/UnitTests/folder needs no registration.dotnet test ... -- --platform=mac|gtk|wpf|winformsoverrides platform detection.
MacBase.AddMethod and ObjCExtensions.ClassAddProtocol both resolve Class.GetHandle(view.GetType()),
so anything they add applies to every instance of that native class, app-wide, forever. The delegate
bodies compensate by looking the handler up per instance (MacBase.GetHandler(obj)), but conformance
does not — e.g. hooking TextInput on one Drawable used to make every EtoDrawableView conform to
NSTextInputClient, so the OS treated them all as text input (AutoFill in their context menus).
MacViewTextInput handles this by also overriding inputContext/conformsToProtocol: to answer per
instance from IMacViewHandler.HandlesTextInput. Apply the same pattern for any new class-level state.
For the same reason a handler's control must never be an instance of a shared base view class: the
methods added for its events are inherited by every view deriving from that base. NativeControlHandler
used a bare MacPanelView, so handling KeyDown on a NativeControlHost gave the window's
EtoContentView a keyDown: too and every Form.KeyDown fired twice. Give each handler its own
subclass, however empty.
e.Handled = true in MouseDown silently kills the control's ContextMenu on Mac.
MacView.TriggerMouseDown only forwards to objc_msgSendSuper when !args.Handled, and it's that super
call to rightMouseDown: that makes AppKit show the view's menu. A handler that blanket-sets Handled
must exclude MouseButtons.Alternate (see DrawableSection.InputMethodDrawable).
Verifying such overrides needs raw objc_msgSend. MonoMac's managed members (NSView.InputContext,
NSObject.ConformsToProtocol, …) dispatch via objc_msgSendSuper for managed subclasses
(IsDirectBinding == false), which skips the very override you added — so the managed API reports the
un-overridden answer and the fix looks broken. Use Eto.Mac.Messaging.*_objc_msgSend* on view.Handle
instead (see Eto.Test.Mac/UnitTests/DrawableTests.IsTextInputClient). AppKit itself calls through
normal dispatch, so it does see the override.
Whether AppKit renders a build with Liquid Glass or the compatibility appearance depends on the SDK the
main executable was linked against, which is not a stable property of a backend: for Eto.macOS that
is the Xcode used to compile it, but for Eto.Mac64 it is the .NET SDK's prebuilt apphost, whose SDK
changes whenever Microsoft rebuilds it. So "Mac64 is pre-Tahoe" has a shelf life — prefer measuring real
values over branching on the appearance, and note that MacVersion.IsUsingGlass (SDK-derived) and
MacVersion.IsAtLeast(26, 0) (OS-derived) answer genuinely different questions.
Handler interfaces have no default implementations (core targets netstandard2.0), so a new member
must be implemented in every registered backend or the build breaks. For a given widget, find
them via grep -rn "<Widget>.IHandler" src/ --include="*.cs" — note Eto.iOS/Eto.WinUI often have
theirs commented out in Platform.cs and so need nothing. On macOS/Linux you can only compile
Eto, Eto.Gtk and Eto.Mac; Eto.Wpf/Eto.WinForms need Windows (net48 also needs the targeting
pack) and Eto.Android needs the android workload — so those edits go in unverified by compile.
Note this is a binary-breaking change for any third-party handler implementation.
SelectedValues/SelectedKeys silently no-op until the control is loaded. The setters iterate
an internal buttons list that isn't populated until CheckBoxList.OnLoad assigns
DataStore = CreateDefaultItems() — and that happens after base.OnLoad raises the Load event,
so a Load handler is still too early. Set an initial selection from LoadComplete (or after
touching .Items, which materializes the data store), never from the constructor.
Also, EnumCheckBoxList's AddValue filter matches on the enum value, not the name, so it can't
tell apart two members that share a value — filtering out an aggregate like All also drops any
single flag that happens to equal it, and the list can come up empty. Same reason Enum.GetNames
reports both names for that value.
Enum members are inlined into consumers as compile-time constants, so redefining an aggregate
(All = A → All = A | B) silently leaves already-compiled callers passing the old bits. Where a
value means "all of it, whatever gets added later", give it every bit: All = ~0 (see
ContextMenuSystemItems; the BCL does the same with EventKeywords.All = -1). Handlers must then
test value != None rather than value.HasFlag(All), which only matches when every bit is set.
ToString() still prints All, since exact member matches win over bitfield decomposition.
Note MenuBarSystemItems.All = Common | Quit predates this and has the older shape.
The test + platform projects pick a backend by TargetFramework:
net10.0→ Gtk (Linux) and Macnet10.0-windows/net48→ Wpf (Windows only)
Core Eto project targets netstandard2.0;net6.0;net8.0;net10.0.
Platform backends live in src/Eto.<Platform>/ (Gtk, Mac, Wpf, WinForms, WinUI, iOS,
Android, Direct2D). Solution: src/Eto.slnx.
- GTK returns a stale preferred size on the first size request after
ShowAllon an unrealized/unmapped widget (esp.GtkTreeView); the settled value only comes back on a subsequent request.GtkControl.GetPreferredSizeForControlprimes this with a throwawayGetPreferredSizecall (GTK3 only) so measuring a control before it's shown is correct — and so a control's standaloneGetPreferredSize()matches its size when measured inside a container (the invariantControlsShouldHavePreferredSizeenforces). GtkTreeViewcell renderers report size0until the tree is mapped on a real on-screen window — realizing, pumping the event loop, and evenGtk.OffscreenWindowdo not make them measure. So grid height can't be measured natively before first show;GridHandlercomputes it explicitly from row count × row height (falling back to the Pango font line height when the renderers report 0) and feeds it intoEtoScrolledWindow.OnGetPreferredHeightso both direct and in-container measurements are correct.
- C# (
*.cs) uses tabs, indent size 4 (per.editorconfig). Project/props files (*.csproj,*.props,*.targets,*.slnx) use 2-space. - Braces on their own line (
csharp_new_line_before_open_brace = all).