This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
dotnet restore # Restore dependencies
dotnet build # Build solution
dotnet test # Run all tests
dotnet test --filter "FullyQualifiedName~Name" # Run specific test
dotnet run --project examples/ImGuiAppDemo # Run main demo
dotnet run --project examples/ImGuiWidgetsDemo # Run widgets demo
dotnet run --project examples/ImGuiStylerDemo # Run styler demo
dotnet run --project examples/ImGuiPopupsDemo # Run popups demo
dotnet build -c Release # Build release configurationThis is the ktsu ImGui Suite, a collection of .NET libraries for building Dear ImGui applications. The solution (ImGui.sln) uses:
- ktsu.Sdk - Custom SDK providing shared build configuration
- MSTest.Sdk - Test project SDK with Microsoft Testing Platform
- Multi-targeting:
net10.0;net9.0;net8.0for libraries,net10.0for tests
- ImGui.App (
ktsu.ImGui.App) - Application foundation with windowing, rendering, font/texture management, PID frame limiting, DPI awareness. Image decoding is self-contained (ImGui.App/Images/), so the package carries no imaging dependency; see Image decoding below. - ImGui.Widgets (
ktsu.ImGui.Widgets) - Custom UI components, grouped as the README's feature list groups them: input and controls (Switch,SegmentedControl,Stepper,RangeSlider,XYPad,Knob/KnobWithDrag,Rating,Chip/ChipGroup,PinInput,SearchBox/SearchBoxRanked,Combo); display and status (Avatar,Badge/BadgeDot,ColorIndicator,Icon,Text,Image,PageIndicator); progress and loading (RadialProgressBarwithRadialCountdown/RadialCountUp,SkeletonLine/SkeletonRect/SkeletonCircle); data and signals (Histogram,HandleTrack,CurveTrack,DbMeter,Scope); layout and containers (DividerContainer/DividerZone,Grid,TabPanel,Card,Tree,ImageCanvas,PropertyGrid,OverlayHost/OverlayLayer,ScopedDisable,ScopedId); and motion and gestures (Tween,Spring,Easing,InertialScroll,GestureDetector/GestureMachine). Also thin adapters delegating toHexa.NET.ImGui.Widgets:Spinner,BufferingBar,HorizontalSplitter/VerticalSplitter,ToggleSwitch/ToggleButton/TransparentButton/InlineButton,IconTreeNode,EnumCombo,TextCenteredV/TextCenteredH/TextCenteredVH,ImageCenteredV/ImageCenteredH/ImageCenteredVH/ImageScaleTo,Tooltip,Breadcrumb,DatePicker/YearPicker,FlameGraph,FileTreeView,OpenFileDialog/SaveFileDialog/OpenFolderDialog,RenameDialog,DialogMessageBox/ShowMessageBox,DockedWindow. Seven of these look like duplicates of an existing ktsu widget; most are not, and the two that are have a recommended survivor — see Hexa-backed vs ktsu widgets below for the pair-by-pair verdict.DatePickerandFileTreeViewneed a Material Icons font registered viaFontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true)(notImGuiAppConfig.Fonts, which applies the Nerd Font mapping); seeexamples/ImGuiAppDemo.YearPickerneeds no icon font.OpenFileDialog,SaveFileDialogandOpenFolderDialogneed the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs;RenameDialog,DialogMessageBoxandShowMessageBoxneed none.DockedWindowcomposes Hexa'sImWindowinternally rather than inheriting it — subclass it, overrideTitleandDrawContent(), then callShow()/Close(). All of the dialogs andDockedWindowrequire a per-frame deferred-drawing pump; see Deferred Drawing below. Also includes callback-driven editors:Sequencer,SequenceSource,CurveEditor,CurveSource,CurveData,BezierEditor. Unlike the dialogs above, none of these need a deferred-drawing pump; see Callback-driven editors below. - ImGui.Popups (
ktsu.ImGui.Popups) - Modal dialogs: MessageOK, Prompt, InputString/Int/Float, FilesystemBrowser, SearchableList - ImGui.Color (
ktsu.ImGui.Color) - Bridge betweenktsu.Semantics.Colorand ImGui. Colors are held as the semanticColor(linear) andSrgbtypes and converted only at the ImGui seam:ColorImGuiExtensions(ToImColor/FromImColor,ToImGuiVector4,ToImGuiU32) andSrgbImGuiExtensions(Srgb→ImColor/ImGuiVector4/ImU32, packed directly with no linear round-trip). TheImColorandSrgbToImGuiU32apply the global style alpha likeImGui.GetColorU32; the linearColor.ToImGuiU32is a pure pack matchingColorConvertFloat4ToU32.ImColorextension operations: adjustments (lighten/darken, saturate/desaturate, hue offset, grayscale, invert, alpha), analysis (relative luminance, contrast ratio, perceptual distance), and contrast heuristics (MostReadableTextColor,AdjustForSufficientContrast). All color math delegates toktsu.Semantics.Color. (There is noImColorfactory class — construct viaColor/Srgband convert.) - ImGui.Styler (
ktsu.ImGui.Styler) - Theming system with 50+ built-in themes, scoped styling, Button.Alignment, Text.Color semantic colors, Indent utilities, Alignment helpers, theme-aware color palette (Palette, e.g.Palette.Basic.Red,Palette.Semantic.Error), and interactive theme browser. Color construction and manipulation live inImGui.Color. - NodeGraph (
ktsu.NodeGraph) - UI-agnostic attribute-based node graph metadata:[Node],[InputPin],[OutputPin],[NodeExecute],[NodeBehavior], pin type utilities - ForceDirectedLayout (
ktsu.ForceDirectedLayout) - Renderer-agnostic graph layout simulation, with no UI dependency and no runtime package dependencies. Bodies repel across the clear space between their bounding boxes (not between their centres — see Layout benchmarking), edges pull like springs between the pins they actually attach at, gravity holds the graph together, edges are pulled towards horizontal, an overlap pass separates any boxes left drawn over one another, and a recentring pass slides the whole arrangement so its drawn box sits on the world origin (see Placement is not cohesion). Three surfaces over oneLayoutCore: a generic facade over your own types, an id-basedForceLayoutfor bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI.ImGui.NodeEditoris one consumer. - ImGui.NodeEditor (
ktsu.ImGui.NodeEditor) - ImNodes-based visual node editor withNodeEditorEngine,AttributeBasedNodeFactory, physics-based layout,NodeEditorRenderer,NodeEditorInputHandler.PhysicsSettingsPanel.Draw(ref PhysicsSettings)draws every layout setting grouped by force and captioned, andDrawDiagnostics(engine)the live energy and settled state, so a consuming application gets the whole tuning surface rather than reimplementing a subset of it. ImNodes has no zoom of its own, soNodeEditorRenderer.Zoomsupplies one andFitToViewcentres a graph and picks the zoom it fits at; the engine's positions and sizes stay at their own scale throughout, since that is the space the layout's lengths are measured in. Hovering is answered by the renderer:HighlightLinksOnNodeHover(on) colours the links meeting the hovered node,HighlightDownstreamOnNodeHover(off) also colours everything that node's value reaches, andDrawHoveredLinkOnTop(on) redraws the hovered link over the nodes ImNodes drew on top of it. See Hover highlighting below. How many links a pin accepts is the pin's own business:Pin.AllowsMultipleConnectionsdefaults to many for an output and one for an input,[InputPin(AllowMultipleConnections = true)]/[OutputPin(AllowMultipleConnections = false)]override it through the factory, andNodeEditorEngine.SetPinAllowsMultipleConnectionssets it directly.GetOutgoingLinks,GetIncomingLinks,GetDownstreamandGetUpstreamwalk the graph - ImGui.Markdown (
ktsu.ImGui.Markdown) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered onImGui.Coloronly, with no dependency onImGui.App. StaticImGuiMarkdown.Render(string, MarkdownConfig?)parses with an internal source-keyed cache;MarkdownDocumentparses once for hot render paths.MarkdownConfigexposesFontResolver,OnLinkClicked,ImageResolver,HeadingScales,WrapWidth,ListIndentPixels,ParagraphSpacingPixels, andLinkColor. Heading sizes derive from the live font size, so DPI andImGuiApp.GlobalScaleare respected automatically. Bold/italic use real glyphs when the host app registers named font variants viaFontResolver, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go toMarkdownConfig.CodeBlockRenderer(Action<string?, string>?— the fence's info string and the block text) when one is supplied, which takes over drawing and reserving the block's layout space;ImGui.SyntaxHighlightingplugs into it, and neither library references the other. v1 has no built-in code-block syntax highlighting, no async remote image download, and renders HTML as escaped text. - SyntaxHighlighting (
ktsu.SyntaxHighlighting) - Renderer-agnostic tokenizing: no ImGui, no graphics API, no third-party parser, so it can move to its own repository unchanged.SyntaxHighlighter.Highlight(code, language, tabWidth)returns the classifiedHighlightedLine/HighlightedTokenruns;SyntaxHighlighter.HighlightCachedgoes through a bounded cache keyed by source, language and tab width;HighlightedCodetokenizes once for hot render paths. Languages are data (LanguageDefinition: line/block comment, string, keyword, type, constant, operator, identifier and embedded-language rules) held inLanguageRegistry, which resolves names and aliases case-insensitively and falls back to plain text for unknown names rather than throwing. Fifteen built-ins inBuiltInLanguages: text, csharp, c, cpp, javascript, typescript, python, json, yaml, xml, html, css, sql, shell, lua. Two tokenizers back them — the generalCodeTokenizer, andMarkupTokenizerfor definitions withIsMarkup(XML/HTML), which classify structurally rather than by keyword.SyntaxThemeholds onektsu.Semantics.Color.ColorperTokenKind, withDark/Lightbuilt in andBackground/Plain/LineNumberleft unset for the host to fill. Comments and strings are searched for an embedded language; see Embedded languages below. Highlighting is lexical. - ImGui.SyntaxHighlighting (
ktsu.ImGui.SyntaxHighlighting) - The Dear ImGui drawing layer overktsu.SyntaxHighlighting, layered onImGui.Coloronly, with no dependency onImGui.App. StaticImGuiSyntaxHighlighting.Render(code, language, SyntaxHighlightConfig?)tokenizes through the shared cache and draws;Render(HighlightedCode, config)draws pre-tokenized code, andHighlightedCodeExtensionsre-addscode.Render(config)as an extension since the tokenized type itself knows nothing about ImGui.Highlightforwards toSyntaxHighlighter.Highlight. LeavingSyntaxHighlightConfig.Themenull picks betweenSyntaxTheme.Dark/Lightper frame from the window background's luminance, and unsetBackground/Plain/LineNumbercome fromFrameBg/Text/TextDisabled. Code is never wrapped, and there is no scrolling, selection or editing.ImGui.Markdown'sCodeBlockRendererplugs into this, and neither library references the other.
examples/ImGuiAppDemo/- Main application demoexamples/ImGuiWidgetsDemo/- Widget showcaseexamples/ImGuiStylerDemo/- Theme galleryexamples/ImGuiPopupsDemo/- Popup demonstrationsexamples/ImGuiMarkdownDemo/- Markdown rendering demoexamples/ImGuiSyntaxHighlightingDemo/- Syntax highlighting demo, including markdown code blocks routed through the highlighter
tests/ImGui.App.Tests/- App framework tests with mock OpenGL provider, plusImages/covering the PNG, JPEG, BMP and TGA decoders and the resampler.TestImageBuilderencodes PNG, BMP and TGA files in memory so the decoders can be driven over their whole feature matrix without binary fixtures; the JPEG cases, which need a real encoder, are small base64 constants inJpegDecoderTests.tests/ForceDirectedLayout.Tests/- The layout simulation: per-force unit tests, the overlap pass, repulsion, andBench/— the benchmark harness every layout claim is measured with. See Layout benchmarking below.tests/NodeGraph.Tests/- Node graph attribute and type utility teststests/ImGui.NodeEditor.Tests/- Engine, factory and rendering tests for the node editor. The engine and factory ones need no context;NodeRenderingTests,ZoomTestsandHoverHighlightTestsdrive real frames throughImGuiAppHarness, since zoom and hover are made of what the renderer writes into ImNodes and reads back out, and neither exists without drawingtests/<Demo>.UITests/- One headless UI test project per example, driving the demo's realBuildConfig()throughImGuiAppHarness:ImGuiAppDemo.UITests,ImGuiWidgetsDemo.UITests,ImGuiStylerDemo.UITests,ImGuiPopupsDemo.UITests,ImGuiMarkdownDemo.UITests,ImGuiSyntaxHighlightingDemo.UITests. See Demo UI tests below.tests/ImGui.Widgets.UITests/- One headless UI test class per widget, each driving that widget alone with nothing else on screen. See Widget UI tests below.tests/SyntaxHighlighting.Tests/- Tokenizer, line-splitter, registry, embedded-language, theme and cache tests. These are pure unit tests againstktsu.SyntaxHighlighting, which has no ImGui dependency at all; the ImGui drawing layer is covered byImGuiSyntaxHighlightingDemo.UITests.
ImGui.App/ImGuiApp.cs- Main static entry point (ImGuiApp.Start(),ImGuiApp.Stop())ImGui.App/ImGuiAppConfig.cs- Application configuration recordImGui.App/PidFrameLimiter.cs- PID-controlled frame rate limiter with auto-tuningImGui.App/FontMemoryGuard.cs- GPU memory management for font atlasesImGui.App/FontHelper.cs- Unicode, emoji, and Nerd Font character range supportImGui.App/ForceDpiAware.cs- Multi-platform DPI detectionImGui.App/WindowingEnvironment.cs- Wayland / tiling window manager detection drivingImGuiAppConfig.WindowGeometryImGui.App/ImGuiExtensionManager.cs- Auto-detection of ImGuizmo, ImNodes, ImPlotImGui.App/Images/ImageDecoder.cs- Front door for image loading; sniffs the format from the file's own bytesImGui.App/Images/ImagePixels.cs- The decoded RGBA8 buffer every decoder produces and the texture cache uploadsImGui.App/Images/PngDecoder.cs- PNG, including every colour type and bit depth,tRNS, Adam7 and all five filtersImGui.App/Images/JpegDecoder.cs- Baseline, extended sequential and progressive Huffman JPEGImGui.App/Images/BmpDecoder.cs- BMP: core and info headers, 1/4/8/16/24/32 bit,BI_RGBandBI_BITFIELDSImGui.App/Images/TgaDecoder.cs- TGA: colour-mapped, true-colour and greyscale, raw and run-length encodedImGui.App/Images/ImageResampler.cs- Separable Lanczos-3 scaling on premultiplied alpha, behindSetWindowIconImGui.Widgets/PropertyGrid.cs- Two-column property grid: options, the path-browse request, and the table/row plumbing (PropertyGridRows.csholds the typed rows,PropertyGridLists.csthe list rows). See Property grid belowImGui.Widgets/DividerZone.cs- Resizable split pane layoutImGui.Widgets/TabPanel.cs- Tabbed interface with drag-and-dropImGui.Widgets/FlameGraph.cs- Hexa-backed flame graph with managed sample marshalingImGui.Widgets/Splitter.cs- Hexa-backed horizontal/vertical draggable splittersImGui.Widgets/DeferredDrawing.cs-DrawDeferred()/DrawDeferredDocked()per-frame pumps, and theToggleSwitchanimation-clock fallback used when neither has ever runImGui.Widgets/DockedWindow.cs- Abstract base for windows drawn byDrawDeferredDocked(); composes Hexa'sImWindowvia a private adapter instead of inheriting itImGui.Widgets/Dialogs/- Hexa-backed dialog wrappers:FileDialogs.cs(OpenFileDialog/SaveFileDialog/OpenFolderDialog),RenameDialog.cs,MessageDialogs.cs(DialogMessageBox/ShowMessageBox),DialogOutcome.cs(sharedDialogOutcomeenum and result mapping)SyntaxHighlighting/SyntaxHighlighter.cs- The renderer-agnostic entry point (Highlight,HighlightCached)SyntaxHighlighting/Tokenizing/CodeTokenizer.cs- Single-pass lexer driven by aLanguageDefinition; emits tokens over the whole source, so block comments and multi-line strings stay wholeSyntaxHighlighting/Tokenizing/LineSplitter.cs- Cuts those tokens into lines, normalizes CRLF, and expands tabs against the column they start atSyntaxHighlighting/Tokenizing/EmbeddedExpander.cs- Replaces a comment or string token with the token run of the language written inside it, and readslang=hint commentsSyntaxHighlighting/Languages/BuiltInLanguages.cs- The fifteen built-in language definitionsSyntaxHighlighting/Languages/EmbeddedContent.cs- The recognizers behind the embedded-language rules (LooksLikeJsonparses, it does not pattern match)ImGui.SyntaxHighlighting/Rendering/CodeRenderer.cs- Draws the background, gutter and colored token runs, then reserves the footprint as one itemImGui.Color/ColorImGuiExtensions.cs-Color↔ ImColor/ImU32/Vector4 conversions (ImColor.ToImGuiU32applies global alpha;Color.ToImGuiU32is pure)ImGui.Color/SrgbImGuiExtensions.cs- DirectSrgb→ ImColor/ImGuiVector4/ImU32 conversions (no linear round-trip)ImGui.Color/ImColorExtensions.cs- ImColor adjustment, analysis, and contrast operationsImGui.Styler/Palette.cs- Theme-aware color palette (Palette.Basic,Palette.Semantic,Palette.Neutral, …) and theme color lookupsImGui.Styler/Theme.cs- Theme management, browser, and selectorImGui.Styler/ScopedColor.cs- RAII-pattern color styling (ImColor/Color/Srgboverloads;ScopedTextColortoo)NodeGraph/NodeAttribute.cs- Core node attributesNodeGraph/PinAttribute.cs- Pin declaration attributesImGui.NodeEditor/NodeEditorEngine.cs- Node graph business logic
- Hexa.NET.ImGui (2.2.9) - Dear ImGui .NET bindings
- Hexa.NET.ImGuizmo (2.2.9) - ImGuizmo gizmo extension
- Hexa.NET.ImNodes (2.2.9) - ImNodes node editor extension
- Hexa.NET.ImPlot (2.2.9) - ImPlot charting extension
- Silk.NET (2.23.0) - Cross-platform windowing and OpenGL
- ktsu.ThemeProvider (1.0.11) - Semantic theming foundation
- ktsu.ThemeProvider.ImGui (1.0.11) - ImGui theming integration
- ktsu.TextFilter (1.5.4) - Text filtering (Glob/Regex/Fuzzy)
- ktsu.FuzzySearch (1.2.2) - Fuzzy search matching
- ktsu.Extensions (1.5.9) - Collection extension methods
- ktsu.CaseConverter (1.3.6) - String case conversion
- ktsu.Semantics.Color (2.7.0) - Physically-grounded color type (linear RGB, Oklab, HSL/HSV, WCAG accessibility, adjustment operations); backs
ImGui.Color - ktsu.Semantics.Paths (1.0.28) - Type-safe path handling
- ktsu.Semantics.Strings (1.0.28) - Type-safe string wrappers
- ktsu.Semantics.Quantities (1.0.29) - Typed quantity calculations
- Hexa.NET.ImGui.Widgets (1.2.18) - Upstream widget collection backing the Hexa-delegated widgets in
ImGui.Widgets - Hexa.NET.ImGui.Widgets.Extras (1.0.9) - Curve editor, bezier and text editor extras; referenced for a future tier but not yet used by any Tier 1 widget. Pulls
Microsoft.CodeAnalysis.CSharp.ScriptingandHexa.NET.Mathinto the dependency graph. - ktsu.Invoker (1.1.2) - Delegate invocation utilities
- ktsu.ScopedAction (1.1.6) - RAII-pattern scoped actions
- Polyfill (9.7.7) - Backport newer .NET APIs
- Markdig - CommonMark markdown parser backing
ImGui.Markdown
Each library exposes a static class as its main entry point, with nested public classes for components:
ImGuiApp.Start()/ImGuiApp.Stop()- Application lifecycleImGuiWidgets.SearchBox(),ImGuiWidgets.Knob(),ImGuiWidgets.Combo(),ImGuiWidgets.RadialProgressBar()- Widget methodsnew ImGuiWidgets.TabPanel(),new ImGuiWidgets.DividerContainer()- Widget instancesnew ImGuiPopups.InputString(),new ImGuiPopups.FilesystemBrowser()- Popup instancesTheme.Apply(),Theme.ShowThemeSelector()- Theme managementButton.Alignment.Center(),Text.Color.Error(),Indent.ByDefault()- Styling utilities
ImGuiApp.Start(new ImGuiAppConfig
{
Title = "App",
OnRender = delta => { /* render code */ },
OnStart = () => { /* init code */ },
PerformanceSettings = new() { FocusedFps = 60.0 }
});The ImGuiAppConfig.OnConfigureFonts callback fires during font atlas initialization, after fonts configured via the Fonts property have been added but immediately before the atlas is built. This is the correct — and only — place to register custom fonts with custom glyph ranges, using FontHelper.AddCustomFont(io, fontData, size, glyphRange, mergeWithPrevious: true). For example, Material Icons requires this:
OnConfigureFonts = () =>
{
ImGuiIOPtr io = ImGui.GetIO();
byte[] fontData = File.ReadAllBytes("MaterialIcons-Regular.ttf");
unsafe
{
FontHelper.AddCustomFont(io, fontData, 16f, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true);
}
}Do not register custom fonts from OnStart — that runs after the atlas has already built, so glyphs silently fail to rasterize. Routing via ImGuiAppConfig.Fonts also does not work for fonts with custom glyph ranges, since that path applies the Nerd Font ranges instead. The callback fires on every atlas rebuild — that means startup plus any DPI change greater than 5%, but not SetGlobalScale, which does not rebuild the atlas — so handlers must be safe to run repeatedly and re-register fonts each time. InitFonts releases the previous run's pinned font data before invoking the callback, so re-registering does not accumulate pinned memory.
Note that FontHelper.GetMaterialIconRanges() claims the entire Private Use Area (U+E000–U+F8FF), which subsumes every Nerd Font range. Merging Material Icons last therefore replaces Nerd Font glyphs across Powerline, Font Awesome, Devicons, Octicons and the rest — not just one narrow span.
ImGui.App/Images/ decodes image files without a third-party imaging library. That is deliberate:
SixLabors.ImageSharp moved to a split licence at 4.0, which broke the build for anyone who let the
version float (#230) and left the package pinned to 3.1.x indefinitely. Issue #354 asked whether the
dependency could be replaced with something more permissive or with code that only does what this
library needs; this is that code, and ktsu.ImGui.App now has no imaging dependency at all.
ImageDecoder.Load(path) / Load(stream) / Decode(bytes) return an ImagePixels: a tightly
packed, straight-alpha RGBA8 buffer with no row padding, which is exactly what
UploadTextureRGBA wants. The format is chosen by ImageDecoder.Identify from the file's own
leading bytes, never from its extension, so a mislabelled file still loads. Anything unrecognised or
malformed raises InvalidImageDataException naming what was found.
What each decoder covers:
| Format | Covered | Not covered |
|---|---|---|
| PNG | All five colour types, bit depths 1/2/4/8/16, palettes, tRNS in all three forms, Adam7 interlacing, all five scanline filters |
Nothing in the base format. Ancillary chunks are skipped, so there is no colour management: gAMA and iCCP are ignored and pixels are taken at face value |
| JPEG | Baseline and extended sequential (SOF0/SOF1) and progressive (SOF2) Huffman, any sampling factors, restart intervals, greyscale and three-component colour, the Adobe transform flag | Arithmetic coding, lossless and hierarchical modes, four-component CMYK/YCCK, 12-bit samples |
| BMP | Core and info headers of every version, 1/4/8/16/24/32 bit, top-down and bottom-up, BI_RGB and BI_BITFIELDS |
BI_RLE4/BI_RLE8, and headers embedding a PNG or JPEG payload |
| TGA | Colour-mapped, true-colour and greyscale at 8/15/16/24/32 bit, raw and run-length encoded, the descriptor's origin and attribute-bit flags | Nothing in common use |
Three details worth knowing before changing any of it:
- 16-bit PNG samples are truncated to their high byte. The destination is an RGBA8 texture, so the low byte has nowhere to go.
- A 32-bit BMP or TGA whose alpha channel is entirely zero is treated as opaque.
BI_RGBleaves the fourth byte undefined and plenty of encoders write zero there; taking that literally would make the whole image invisible. An image with any non-zero alpha keeps its alpha as written. - JPEG holds every coefficient until the last scan. Progressive coding requires it, and sharing the path with baseline keeps one block decoder rather than two. The cost is roughly two bytes per sample of transient memory for the duration of the decode.
Chroma is upsampled by bilinear interpolation on half-offset sample centres, which for the usual 2x factors is the same triangle filter a reference decoder applies. Against libjpeg the decoder lands within about three units per channel — the range such decoders differ by among themselves.
ImagePixels.Resize (and so ImGuiApp.SetWindowIcon) goes through ImageResampler: a separable
Lanczos-3 filter whose support widens by the reduction factor when downscaling, so shrinking averages
rather than point-samples. It resamples premultiplied alpha and unpremultiplies afterwards, so the
colour of fully transparent pixels does not bleed into their visible neighbours.
Hexa's dialogs and DockedWindow are stateful: Show() registers the instance with a static
manager, and it is only drawn by a per-frame pump. Call one of these once per frame, at the end of
OnRender:
ImGuiWidgets.DrawDeferred()— draws every open dialog, message box and popup, and advances Hexa's animation clock. No layout opinion.ImGuiWidgets.DrawDeferredDocked()— additionally creates a dockspace over the main viewport and draws every registeredDockedWindow. It requiresImGuiConfigFlags.DockingEnable, which is set byImGuiAppConfig.EnableDocking = true; without it the pump throwsInvalidOperationException. The flag cannot be turned on from the pump: ImGui only accepts it before the firstNewFrame(), and changing it mid-frame aborts the process on the next frame with"Please set DockingEnable before the first call to NewFrame()!". Hexa'sWidgetManager.Draw()callsDockSpaceOverViewportwithout checking the flag, so without it the dockspace would silently do nothing anyway. Internally it does everythingDrawDeferred()does (via Hexa'sWidgetManager.Draw(), which itself calls Hexa'sDialogManager.Draw(),MessageBoxes.Draw(),PopupManager.Draw()andAnimationManager.Tick()), soDrawDeferred()must not also be called.DockedWindowonly renders under this pump — underDrawDeferred()aDockedWindowis registered but never drawn, because Hexa's widget manager is only driven by that pump. ADockedWindowis dockable but not auto-docked: it opens floating and stays there until the user drags it into the dockspace.
They are mutually exclusive: calling both in the same frame draws every dialog twice.
ImGuiWidgets detects this (via the ImGui frame counter) and logs a Trace.TraceWarning, but does
not throw.
Showing a dialog (OpenFileDialog, SaveFileDialog, OpenFolderDialog, RenameDialog,
DialogMessageBox, or ShowMessageBox) before any pump has ever run throws
InvalidOperationException — otherwise it would never appear, and the manager's internal
collections would grow for the life of the process.
Calling Show() on a dialog instance that is already shown also throws InvalidOperationException.
Hexa's Dialog.Show() adds the instance to its manager unconditionally, so a second Show()
registers the same instance twice; closing removes one entry and the survivor is never drawn, never
closed and never removed, which latches WidgetManager.BlockInput on for the life of the process.
Wait for the close callback, or create a new instance per showing. The guard lives in
ImGuiWidgets.DialogShowGuard and is cleared from the close callback, which Hexa invokes on every
close path including the window's X button.
A pump is not required just to keep animated Hexa-backed widgets correct: ToggleSwitch ticks
Hexa's animation clock itself once per frame whenever no pump has ever run, so it animates
correctly even in an application that never calls DrawDeferred() or DrawDeferredDocked(). Once
either pump runs for the first time, it owns the clock exclusively and the fallback stops ticking.
The pump is required for dialogs, not for animation.
The file dialogs' underlying Close() briefly blocks the UI thread while its async directory-scan
task unwinds (refreshTask?.Wait()).
ImGuiWidgets.PropertyGrid is a two-column ImGui table opened in a using statement, one row per
property, each editing a variable by reference. One overloaded Value row covers bool, int, long,
float, double, string, Vector2/Vector3 and their DoubleVector2/DoubleVector3 counterparts,
and the semantic Color; Enum, FilePath/DirectoryPath/ImagePath and List cover the rest.
Five things are worth knowing before changing any of it:
-
A collapsed section holds its own rows back.
Sectionreturns aSectionScopedisposable rather than a bool, and rows are written inside itsusingwith no test around them: while the section is collapsed the scope raises the grid'ssuppressDepth, and every row returns false without drawing, exactly as it does when the table never opened. It is a depth rather than a flag so a section nested inside a collapsed one stays balanced on its own.SectionScope.IsOpenis there only for skipping work that costs something to prepare before a row can be called. -
A path row never opens a dialog itself.
ktsu.ImGui.Widgetshas no business knowing how the host picks files, and Hexa's own dialogs would drag the deferred-drawing pump in as a hard requirement. The browse button instead raisesPropertyGridOptions.OnBrowsewith aPropertyPathRequest, and because a dialog outlives the frame that opened it, the request carries no reference to the value:Completerecords the answer under the row's ImGui id and the row adopts it on its next draw. Entries whose row never draws again are swept on a later completion, so the table cannot grow for the life of the process. -
Thumbnails come from the host too. Texture upload lives in
ktsu.ImGui.App, which this library deliberately does not reference, so an image row asksPropertyGridOptions.ThumbnailResolverfor a texture id and draws an empty frame when there is none. The resolver is called with whatever is in the row, including half-typed paths, so it must not throw — the demo checksFile.Existsfirst. -
Every button's id carries its row's label. Each row draws into one id stack, so a constant
"...##browse"or"+##add"would make every browse button in the grid — and the add buttons of two different lists — the same item to ImGui, and clicks would land on the wrong one. This was a real bug caught byPropertyGridTests; keep the label in the id. -
List elements are labelled
[i]inside aScopedIdfor the list. That is what makes their probe names<list>/[0]rather than a bare[0]shared by every list in the grid, and a bare one is ambiguous toItemProbe, so tests address elements asTags/[0]and their remove buttons asTags/[0]/remove.
Seven widget pairs were recorded as deliberate duplication pending a verdict (#338). The verdict: five of the seven are not duplicates at all, and the two that are have a clear survivor. Nothing is obsoleted yet — that is a breaking change and the call is the maintainer's — but do not add a third implementation of any of these, and prefer the survivor column when writing new code.
| Pair | Verdict | Why |
|---|---|---|
DividerContainer vs HorizontalSplitter/VerticalSplitter |
Keep both | Not the same thing. DividerContainer is a retained layout container that owns a list of DividerZones, each with its own draw callback, lays them out across the content region, persists sizes (GetSizes/SetSizesFromList), fires onResized, and nests. A Splitter is one stateless drag handle mutating a single ref float. The comparison tab cannot even show them side by side — its splitter row points at the Advanced Demos tab for the container. |
Tree vs IconTreeNode |
Keep both | Not the same thing, and they compose. Tree is a ScopedAction that draws connector lines around whatever is nested inside it: no label, no collapsing, no ID-stack interaction. IconTreeNode is a real collapsible node with an icon glyph and ImGuiTreeNodeFlags, and the caller must TreePop(). TreeTests drives both in one class for exactly that reason. |
RadialProgressBar/SkeletonLoader vs BufferingBar/Spinner |
Keep all four | Four different widgets, not two pairs: determinate radial (with text modes plus RadialCountdown/RadialCountUp), determinate linear, indeterminate spinner, and a shimmering placeholder block. The only overlap is "determinate progress", where radial versus linear is a visual choice. |
TextCentered vs TextCenteredV/H/VH |
Keep all | Only one true pair. Alignment.Center builds a container of (ContentRegionAvail.X, contentSize.Y), so its vertical extent is the content's own height and vertical centring is a no-op — TextCentered is horizontal-only, making it equivalent to TextCenteredH. TextCenteredV and TextCenteredVH have no ktsu equivalent, and TextCenteredWithin(text, size, clip) with its ellipsis clipping has no Hexa equivalent. Splitting either family to retire one member costs more than the overlap does. |
ImageCentered vs ImageCenteredV/H/VH |
Keep all | Same shape as the text pair, plus one hard difference: ImageCentered returns bool for a click, the Hexa three return void. ImageCenteredWithin has no Hexa equivalent and ImageScaleTo has no ktsu one. |
Switch vs ToggleSwitch |
Genuine duplicate — prefer Switch |
Identical signature, (string label, ref bool value) -> bool. Three differences, all favouring Switch: it calls ImGuiProbes.MarkItem, so a UI test addresses it by name (SwitchTests needs no Mark call at all, while every Hexa-backed suite does); it animates from ImGui.GetIO().DeltaTime rather than Hexa's animation clock, so it needs none of the TickAnimationClockIfUnpumped machinery ToggleSwitch requires to avoid rendering inverted after its first click in a pumpless app; and it draws the visible portion of the label itself. |
Combo vs EnumCombo |
Overlap on one overload — prefer Combo unless you need Hexa's display names |
Only Combo<TEnum> overlaps; the ISemanticString and string collection overloads are unique. The enum overloads differ in three ways: Combo shows raw Enum.GetNames while EnumCombo routes through Hexa's ComboEnumHelper<T>.GetName (where a display-name override would come from); Combo marks itself for probes and EnumCombo does not; and the constraints differ (where TEnum : Enum vs where T : struct, Enum). If nothing depends on Hexa's naming, EnumCombo is the one to retire. |
The through-line: the ktsu originals are probe-visible and the Hexa adapters are not. Only
Switch and Combo call ImGuiProbes.MarkItem; every Hexa-backed widget has to be marked by the
test itself, or reached by geometry. That is the single most consequential difference between the
two families, and it is a reason to reach for a ktsu original in an application you intend to
UI-test.
ImGuiWidgets.CurveTrack is a tone-curve control and is unrelated to the CurveEditor overloads
below, which edit animation keyframes through Hexa. Both are kept: they are different tools, and
measurement is what separated them. Against CurveData.Sample, the editors' evaluator:
- returns 255 distinct values across 0..1 — a 256-entry cache read with a truncating index;
- is off the identity by up to 0.003920, one 8-bit step, biased downward;
- is not monotone — a four-point curve decreases by 0.00078 near the middle;
- returns 0 everywhere for
CurveShape.Freehand.
None of that matters on a keyframe track, where overshoot is expressive and 8-bit sampling is invisible. All of it matters on a curve applied to pixels, where a curve that dips inverts the image and a neutral curve that is off by a step darkens it.
Two things to know before changing CurveTrack:
- It does not own the interpolation. The caller passes a
Func<float, float>and the widget plots that. A curve editor is the one control whose drawing is its specification, so taking the function in makes the drawn curve and the applied curve the same function rather than two that agree today.CurveTrackStateholds the interaction — picking, dragging, adding, removing — with no ImGui dependency, tested without a context, exactly asHandleTrackStateis. - It overlays a rectangle rather than reserving one, like
HandleTrackand for the same reason: it is designed to sit on aHistogramdrawn into the same rectangle. It paints no background and restores the cursor, so a caller with nothing underneath reserves the box with a degenerateHistogramcall, which is defined to draw exactly the empty frame. Because it restores the cursor, ImGui asserts if a window's last act is aCurveTrackand nothing follows it — submit something afterwards, as any real layout already does.
ImGuiWidgets.Sequencer and the multi-curve ImGuiWidgets.CurveEditor(CurveSource, Vector2, string)
take a source object they interrogate while drawing, rather than a value:
- Subclass
ImGuiWidgets.SequenceSourcefor a timeline: implementFrameMin,FrameMax,ItemCount,GetItem(int), andSetItemRange(int index, int start, int endFrame)(the third parameter is the clip's new end frame, not a generic "end" value), which receives drag edits.GetItemLabel,ItemTypeNames,AddItem,DeleteItem,DuplicateItem,Copy,Paste,GetCustomHeight,DoubleClick,BeginEditandEndEditare virtual with no-op/empty defaults. - Subclass
ImGuiWidgets.CurveSourcefor a multi-curve graph: implementCurveCount,ViewMin,ViewMax,GetPointCount(int),GetPoints(int),GetCurveColor(int),EditPoint(int, int, Vector2)andAddPoint(int, Vector2).IsVisible,GetInterpolation,BackgroundColor,BeginEditandEndEditare virtual with sensible defaults.
Neither entry point calls or requires DrawDeferred()/DrawDeferredDocked() — they are immediate-mode
calls that happen to take a callback object, drawn inline wherever you call them. SequenceSource and
CurveSource themselves are plain abstract classes with managed members only; no vendor type and no
unsafe appears in either class's public surface (the unsafe in Sequencer's and the multi-curve
CurveEditor's own signatures is confined to the adapters that marshal to Hexa internally).
ImGuiWidgets.CurveEditor also has a single-curve overload — distinguished from the CurveSource
overload by its first parameter's type and the overload's arity — that edits a CurveData value:
CurveEditor(CurveData curve, Vector2 size, Vector2 rangeMin, Vector2 rangeMax, ref int selection, string label). ImGuiWidgets.BezierEditor(string label, ref BezierControlPoints points, float size = 128f) edits a BezierControlPoints pair (First/Second) directly, with no source object.
CurveData wraps the curve representation the widget expects, exposing PointCount, a mutable Shape
(CurveShape.Smooth or .Freehand), and GetPoint/AddPoint/SetPoint/RemovePoint/Clear over
CurveKnot values (a Position plus a CurvePointKind of .Smooth or .Corner). It tracks a dirty
flag internally: every one of those mutators, the Shape setter, and an edit made through the
CurveEditor(CurveData, ...) overload all mark it dirty, so Sample(float t) recomputes the
underlying sample cache on its next call rather than returning a stale value.
ktsu.SyntaxHighlighting looks inside comments and strings for another language, because XML doc
comments, JSON fixtures and SQL queries are all written inside a host language's literals. Each
LanguageDefinition carries EmbeddedLanguages, a list of EmbeddedLanguageRule tried in order,
first match wins; an empty list turns the feature off for that language. The built-ins use
BuiltInEmbeddedRules.Default (JSON, markup, SQL) for programming languages and .Data (JSON,
markup) for JSON, YAML and SQL, plus XmlDocComments for C# only.
Four properties are what make this safe to leave on by default, and are worth preserving when adding a rule:
- Recognition is strict.
EmbeddedContent.LooksLikeJsonparses — a fragment, a trailing word, or a brace-heavy sentence is rejected. Markup must open with a tag and end with>. SQL must open with a statement keyword and use a second one, and its rule isEmbeddedHosts.StringLiteralonly, so "Update the cache and carry on" in a comment is never a query. - Unclassified embedded text keeps its host's kind. A
TokenKind.Plaintoken from the inner tokenizer is re-emitted asComment,DocCommentorStringLiteral, which is why prose between doc comment tags still reads as a comment and why a false positive costs a few punctuation glyphs rather than a paragraph. - The rendered text is never rewritten. Escapes are resolved so the inner tokenizer sees
{"a": 1}where the source holds{\"a\": 1}, but each token is re-sliced from the original through an index map, andEmbeddedExpanderverifies the run reconstructs the host token before keeping it — falling back to the unexpanded token if it ever did not. - Embedding is one level deep. Expansion tokenizes with
expandEmbedded: false, so definitions cannot cycle however they refer to each other.
A // lang=json or /* language=sql */ comment names the language of the next string literal
outright, for snippets no recognizer can catch. Hints are only read when the host language has at
least one embedded rule, and each is spent on one literal.
Two consequences to know: a /// doc comment is a separate token per line, so an XML construct
split across lines is classified per line; and the markup tokenizer ignores EmbeddedLanguages
entirely, so <script> and <style> bodies in HTML are still markup text.
// ScopedColor accepts a semantic Color, an Srgb, or an ImColor
using (new ScopedColor(ImGuiCol.Text, Color.FromHex("#ff6b6b")))
{
ImGui.Text("Styled text"); // Auto-restored after block
}
// Theme-aware palette entries are ImColor values
using (new ScopedColor(ImGuiCol.Button, Palette.Semantic.Success))
{
ImGui.Button("Themed button");
}
using (Text.Color.Error())
{
ImGui.Text("Error message");
}
using (new ScopedDisable(true))
{
ImGui.Button("Disabled button");
}
using (Button.Alignment.Center())
{
ImGui.Button("Centered text", new Vector2(200, 30));
}The node graph system follows a clean separation of concerns:
-
NodeGraph (UI-agnostic): Attribute-based metadata for declaring nodes, pins, execution modes, and type compatibility. No dependency on any rendering library.
-
ImGui.NodeEditor: Renders and interacts with the graph using ImNodes. Split into:
NodeEditorEngine- Business logic (nodes, links, physics)AttributeBasedNodeFactory- Creates nodes from attribute-decorated typesNodeEditorRenderer- Pure ImNodes rendering, and the view:ZoomandFitToViewNodeEditorInputHandler- Input event processing
The renderer writes each node's position into ImNodes on the frame it draws it, so the view is made of node positions rather than of panning: a pan is undone as soon as it is read back, which is why
FitToViewmoves the nodes. Zoom is applied on the way into ImNodes and undone on the way back out, so nothing zoomed ever reaches the engine.
ImNodes only answers what the pointer is over once the editor has ended, so NodeEditorRenderer
asks after EndNodeEditor and highlights on the next frame. A frame's lag on a pointer that has to
rest on a node to mean anything is not one anybody sees, and it is what lets the colours be pushed
per node and per link: ImNodes copies a link's three colours, and a node's outline colour and border
thickness, out of the style as each is submitted, so PushColorStyle around one submission colours
that one thing.
Two consequences worth knowing:
- A hovered link is drawn twice. ImNodes puts every link in a channel below the nodes and offers
no way to lift one out, so
DrawHoveredLinkOnTopdraws the same cubic bezier again — between the same two pin positions, with control points a quarter of the straight-line distance to either side, which is the curve ImNodes itself draws — on the foreground draw list, clipped to the editor. It has to be the foreground list: the editor is a child window, and a child window's drawing is composited over its parent's, so an overlay on the window draw list ends up underneath the nodes.RenderDebugOverlaysdraws there for the same reason. - ImNodes resolves a hovered node before a hovered link. The part of a link that passes behind a node cannot be pointed at, only the part in the open, which is why the overlay is about following a link you have already caught hold of rather than about grabbing one.
- PID frame limiter with auto-tuning (Coarse/Fine/Precision phases)
- Throttled rendering: Different FPS for focused/unfocused/idle/minimized states
- Font memory management via
FontMemoryGuardwith GCHandle pinning and GPU detection - Texture caching with concurrent dictionary, auto-cleanup on context change
- ImGui extension auto-detection via reflection for ImGuizmo, ImNodes, ImPlot
- Physics-based node layout with force-directed simulation, spring links, and stability detection
- Dear ImGui paradigm: Immediate mode - render every frame, no retained state
Tests use MSTest.Sdk with the Microsoft Testing Platform. The ImGui.App tests use a mock OpenGL provider (MockGL, TestOpenGLProvider) to test rendering logic without a real GPU. NodeGraph tests validate attribute scanning, pin type utilities, and node factory behavior.
dotnet test # Run all tests
dotnet test --filter "FullyQualifiedName~TestGL" # Run specific test classDo not pass --nologo to dotnet test. On Microsoft Testing Platform projects it reports
Zero tests ran and exit code 5 instead of running anything, which looks exactly like a broken test
project (dotnet/sdk#55309). Running the produced test executable directly is the way to confirm.
Iterating on a force in ktsu.ForceDirectedLayout by running a graph once and looking at the result
does not work: the simulation is chaotic, so which local minimum one starting arrangement falls into
says nothing about the change that was made. A single-start assertion flips between passing and
failing across parameter values that are all perfectly reasonable — Repulsion_IsWhatSpreadsAGraphOut
used to, passing at repulsion 1,200,000 and 600,000, failing at 800,000, and passing again at 400,000.
tests/ForceDirectedLayout.Tests/Bench/ is the harness that replaces that:
GraphCorpus— six graphs that break a layout differently.Counteris a real twenty-node document with sizes running from a 60-wide literal to a 118x180 function;TwoClassesis a thirty-four-node document with two roots and calls crossing between them, which is where a link gets drawn through a body parked in the middle;Chainis the shape that most wants to be a horizontal row;FanInis eight sources arriving at eight pins on one target, which is where crossings come from;MixedSizesalternates 400-wide slabs with 50-wide literals;Disconnectedis three components with no link between them, the only shape that measures what gravity is for. Node sizes and pin rows are not decoration — repulsion measures clear space between boxes, every angle force measures between pins, and a graph of equal-sized points exercises none of it.LayoutMetrics— settled area, mean edge angle, links drawn across a body they are no end of, tightest and mean clear gap, worst overlap, twisted link pairs, and whether it settled. Read them as a row: a collapse into a crushed ribbon flatters both the area and the angle while being the worst outcome available.LayoutBench.Run/.Sweep/.Compare/.Table— settles a configuration over several starting arrangements (walked from nodes piled on top of each other to nodes flung a thousand units apart) and renders the rows as a fixed-width table. Deterministic: the same settings measure the same twice, so the difference between two rows is the setting and nothing else.LayoutSvg— writes a settled graph to SVG, links drawn first as the cubic the renderer actually draws and nodes over them, so a link hidden in the picture is a link hidden in the editor. Overlapping bodies are outlined in red. No window, no GPU, no ImGui context.LayoutScore/LayoutTuner— one weighted number per configuration, and a coordinate descent over it. The score is a judgement call written down, and its weights are the thing to argue with first if you disagree with a tuned default.
The corpus score is a random variable, and it is noisier than the gains a tuning run chases.
Measured over independent families of starting arrangements (BenchOptions.StartOffset), the same
settings score with a standard deviation of 0.317 at twelve starts, 0.096 at twenty-four and 0.032 at
forty-eight. Two descents run at eight and twelve starts, keeping every improvement, reached values
that disagreed on six settings out of fifteen and scored within 0.01 of each other — both were fitting
the arrangements they were handed. Raise Starts until the deviation is small against the gain being
claimed, keep LayoutTuner.DefaultMinimumGain a few times above it, and validate the result on a
StartOffset family it was not chosen on.
To iterate: add a scratch [TestMethod] that prints a sweep, run the suite, read the column that
should have moved.
Console.WriteLine(LayoutBench.Table(LayoutBench.Sweep(
GraphCorpus.Counter, LayoutSettings.Defaults, "repulsion",
[300_000, 600_000, 1_200_000],
(s, v) => s with { RepulsionStrength = v })));
LayoutCore core = GraphCorpus.Counter.Start(LayoutSettings.Defaults, seed: 1, spread: 0.5);
core.Solve(maxIterations: 6000, tolerance: 0);
LayoutSvg.Write(core, "/tmp/counter.svg");Two things that bite:
- Console output only shows for tests the runner renders a block for, which by default is failing
ones. Pass
--show-stdout All --show-test-results allto the test executable to see a sweep printed by a passing test. - The analyzers apply to scratch tests too.
IDE0005(unused using),IDE2001(embedded statement on one line) andIDE0055(formatting) are errors here, so a quick{ s.X = v; return s; }lambda will not build. Use awithexpression, or put the body on its own lines.
Corpus_SettlesIntoAReadableShape_UnderTheDefaults is the quality gate a layout change is expected to
break if it makes things worse. Its per-graph thresholds are current behaviour with headroom, not
targets.
LayoutSettings.Defaults is measured, not inherited. A coordinate descent over the corpus
(LayoutTuner, scored by LayoutScore) moved six of the fifteen settings and left the other nine
where they were; the corpus score went from 3.25 to 1.19 and held at 1.22–1.28 on three families of
starting arrangements the values were never chosen on.
| setting | was | is |
|---|---|---|
RepulsionStrength |
600,000 | 900,000 |
MinRepulsionDistance |
50 | 5 |
LinkSpringStrength |
0.5 | 0.1 |
RestLinkLength |
225 | 50 |
DirectionalBias |
0.5 | 4 |
LinkFlatteningStrength |
0.5 | 3 |
That fixed the defect this corpus was built to expose. Centre gravity used to coil a long chain: a
plain twelve-node chain settled at about 53 degrees mean edge angle with two starts in six reading
left to right, and it was not a settling-time problem (4000, 12000 and 30000 frames all landed on
52.6). It now settles at 0.1 degrees, twelve starts in twelve, and MixedSizes — a chain too — went
from 50 degrees and 1/12 readable to 2.1 degrees and 12/12.
Gravity is not what fixed it. Weakening GravityStrength did straighten the chain, but at the
cost of the one thing gravity is for, and the descent left it at 50 untouched. LinkFlatteningStrength
at six times its old value simply outcompetes the coil: a force pulling each edge towards horizontal
beats one pulling every body towards a point, and neither has to be turned off for that to be true.
Four things to know before changing any of it:
- Flattening hides links, monotonically. Links drawn across a body they are no end of go from 0.10
to 0.40 (normalised) as the setting goes 0 → 12, and roughly double on
TwoClasses, whose cross-class calls are the long edges that have to cross whatever is parked between them. That is the price of everything above. The score's minimum is a shallow basin — 2 and 3 land within half a standard deviation of each other, 2 hiding fewer links and settling slightly less reliably — so that particular choice is a judgement call and not a measurement. MinRepulsionDistancedoes nothing belowsqrt(RepulsionStrength / MaxForce), which is about 13 under these defaults.MaxForcecaps the total force at 5,000 while the law's own cap is 36,000, so close-range repulsion is a constant 5,000 and the law only reappears past 13 units of clear space. Sweeping the setting across 0, 2, 5 and 10 measures the same layout four times. What it really controls is whether that floor is hard or soft: set it high enough that the law's cap falls belowMaxForceand repulsion goes soft at close range, which is what used to let bodies crowd.- Zero is not a valid
MinRepulsionDistance, and the library now floors it. It is itself the clamp keeping the inverse-square law finite where boxes touch, so zero used to yield infinity and then NaN positions — a layout that stops being numbers rather than one that is merely bad. - A mechanism test must pin the settings its mechanism depends on. Three tests in
ForceLayoutTestsbroke on this tuning without anything being wrong: the flattening splay stopped splaying because the pair now levels completely, and the untwist's overlap-pass exemption became unobservable because flattening decides that geometry outright. Their fixtures now name every input they rely on.
Gravity holds a graph together; it does not decide where the graph sits. A separate pass does that, and the split is not incidental — the two obvious ways of merging them both fail, in ways worth knowing before touching either.
Gravity pulls every body the same amount whichever side of the target it sits and however far out.
Summed over a graph that is a step function of position: it counts bodies rather than measuring them,
so anywhere the counts balance it is exactly zero and nothing holds the graph anywhere at all. A
twelve-node chain settled 79 units to one side and stayed; pushed 600 the other way it came to rest 79
units to the other side — the same distance out, on whichever side it arrived from, because both
are edges of the same dead band. And where the counts do balance is the median of the body centres,
which for a document with a dense cluster of literals on one side and a few large functions on the
other is nowhere near the middle of what is drawn: 200 units apart on GraphCorpus.Counter.
Making gravity proportional to distance fixes both of those and costs something worse. A body further
out is then pulled harder, so wide nodes are squeezed closer together than narrow ones and settled
spacing depends on node size again — which is precisely what measuring repulsion across clear space
rather than between centres was for. Measured, a 400-wide pair settled 160 apart against a 60-wide
pair's 224, and SettledPairs_KeepTheSameClearSpace_WhateverTheirSize fails.
So LayoutCore.RecentreOnOrigin slides the whole arrangement instead, positionally, after
integration. Every body moves by the same vector, so no distance between any two of them changes.
Three details:
- It is positional, not a force. As a force it was clamped per body by
MaxForce, and a body already at the ceiling lost its share while its neighbour kept theirs — which reshaped the graph, the one thing the design exists to avoid. - It stands down when any body is pinned or frozen, since whoever pinned it is saying where the graph goes.
- It is gated on
OriginAnchorWeight, so a test isolating one force should set that to zero or a position it asserts will include the slide.
Measured over the corpus: mean offset from the origin 62.7 → 5.4 units, worst 172 → 19.5, with the
corpus score unchanged (1.194 → 1.166, inside noise). RecentringTests covers it, and a re-sweep of
all fifteen settings afterwards moved none of them — GravityStrength included, which stays at 50.
Each example has a headless UI test project under tests/<Demo>.UITests/, built on
ktsu.ImGui.App.Testing. They render through the CPU rasterizer with no window and no GPU, inject
input straight into ImGui, and advance frames under the test's control, so they neither steal focus
nor need a display.
The pattern each suite follows:
[assembly: DoNotParallelize] // ImGui contexts are global; only one harness may be live
[TestInitialize]
public void SetUp()
{
ImGuiPopupsDemo.ResetState(); // demo state lives in statics that outlive a harness
harness = ImGuiAppHarness.Start(ImGuiPopupsDemo.BuildConfig(), DemoViewport);
harness.Step(2);
}
harness.Click("Show Custom Prompt");
harness.Step(2);
harness.Click("prompt/Maybe");
Assert.AreEqual("User clicked Maybe", ImGuiPopupsDemo.lastPromptResult);Things that bite when writing these:
- Each demo exposes
BuildConfig()(internal, withInternalsVisibleTofor its UITests assembly) so a test drives the real configuration rather than a copy, plusResetState()because demo state is static and outlives the harness. - Use
WasSeenInFrame, notRect, to ask "is this on screen now".ItemProbe.Rectreturns the last position an item ever occupied and never expires, so it cannot tell a closed popup from an open one. Every suite wraps this asIsVisible(name) => harness.Probe.WasSeenInFrame(name, harness.FrameCount - 1). - Give the harness a viewport tall enough for the whole demo. Items below the fold are still recorded by the probe, but clicking their recorded position lands on whatever is actually there, so the click silently does nothing. The tabbed demos use 1600x1200 or larger.
- Run in Release. The software rasterizer is roughly 17x faster there — about 180 ms per frame versus 3 s in Debug — so keep frame counts modest either way.
- The demos mark their own controls.
ktsu.ImGui.Widgetsandktsu.ImGui.Popupsmark items themselves; the plain ImGui buttons, headers, tabs and sliders the demos draw go through a smallDemoProbehelper (or a localDemoButton/DemoHeader/DemoTab) that callsImGuiProbes.MarkItemright after submitting the widget. - Names are qualified by window and scope, and lookups match trailing segments. A leaf name that
appears in two windows is ambiguous and
Rectthrows rather than guessing, so qualify it (harness.Probe.Matches("Username").Single(n => n.Contains("FormExample"))) or pick a distinct one.
tests/ImGui.Widgets.UITests/ covers every widget in ktsu.ImGui.Widgets in isolation: one
test class per widget, whose harness renders that widget and nothing else. The per-demo suites above
prove the examples work; these prove each widget works whether or not any example happens to use it,
and a failure names the widget rather than a demo page containing it.
Tests derive from WidgetTest, which starts a harness around a bare ImGuiAppConfig whose
OnRender is the widget under test, and disposes it on cleanup:
[TestClass]
public sealed class SwitchTests : WidgetTest
{
private bool value;
[TestMethod]
public void Switch_ClickTogglesOn()
{
Start(() => ImGuiWidgets.Switch("Wi-Fi", ref value));
Click("Wi-Fi");
Assert.IsTrue(value);
}
}WidgetTest offers Start/Step, Click/ClickFraction/DragAcross/Hover, IsVisible,
RectOf/CenterOf, Mark/MarkSpan, Snapshot/PixelsChangedSince/BoundsOfDifference,
CreateTestTexture, and the dialog helpers FindDialogButtons/ClickDialogButton/
DismissOpenDialogs.
Things that bite here, beyond the demo-suite list above:
- A widget's probe mark is not always the thing it drew. The alignment helpers (
TextCentered,ImageCenteredWithin,TextCenteredH, …) position the cursor and leave a zero-width spacer as the last submitted item, so a mark taken after the call measures the spacer. Measure those from the pixels withBoundsOfDifferenceagainst a frame drawn without the widget. - Widgets that submit several items, or none, need
MarkSpan.PinInputis a row of separate text boxes;BufferingBarpaints straight into the draw list.MarkSpan(name, cursorBefore)records from the cursor position to the last item's rectangle so a test can aim inside it. - Latch return values. A widget reports a click for the single frame it happens on, and
Clickrenders a further frame after the release, so capture withclicked |= ImGuiWidgets.Chip(...)rather than a plain assignment. - Hexa's dialog managers are process-static. A dialog left unanswered outlives the harness that
showed it and is drawn again over the next test. Any suite that opens one calls
DismissOpenDialogs()from a[TestCleanup]. - Some state is process-global and cannot be tested from here. The pumpless animation-clock
fallback (
TickAnimationClockIfUnpumped) latches off as soon as any test in the assembly runs a pump, so it is covered by the unit tests aroundEvaluateFallbackTickinstead. - Vendor widgets are reached by geometry, not by name. Hexa's dialogs mark nothing:
FindDialogButtons()locates a message box's buttons by finding the lowest run of theme-blue pixels, and the file pickers — whose action row is not blue enough to find that way — are cancelled at a fixed offset from the picker window's corner, which is stable because the harness pins the viewport. - Where a click has to land was measured, not assumed. The flame graph's bars sit in a band near the top of the graph rather than filling it; the sequencer's clips sit just under its header; the page indicator's dots do not divide its row into equal columns. Those coordinates are commented where they appear.
- Add class to
ImGui.Widgets/ - Follow existing widget patterns (static methods or instance classes)
- Add demo to
examples/ImGuiWidgetsDemo/ - Add an isolation suite to
tests/ImGui.Widgets.UITests/
- Add a
LanguageDefinitiontoSyntaxHighlighting/Languages/BuiltInLanguages.csand list it inAll - Reuse the shared comment/string rule fields rather than re-declaring equivalent rules
- Set
EmbeddedLanguagestoBuiltInEmbeddedRules.Defaultfor a programming language, or.Datafor a data format (no SQL rule); leave it empty only when the language has no comments or strings worth looking in - Add tokenizer tests to
tests/SyntaxHighlighting.Tests/CodeTokenizerTests.cs - Add a snippet to
examples/ImGuiSyntaxHighlightingDemo/if the language shows off something new
- Add the recognizer to
SyntaxHighlighting/Languages/EmbeddedContent.cs— strict enough that prose is rejected, since a false positive recolors text that is not code - Add the
EmbeddedLanguageRuletoBuiltInEmbeddedRulesand list it inDefault/Dataas appropriate - Choose
Hostsdeliberately: a rule whose recognizer could fire on English belongs inEmbeddedHosts.StringLiteralonly, the way the SQL rule does - Add tests to
tests/SyntaxHighlighting.Tests/EmbeddedLanguageTests.cs, including a negative one
- Add theme definition to
ImGui.Styler/ - Test in
examples/ImGuiStylerDemo/
- Decorate a class/struct with
[Node]attribute - Add
[InputPin]/[OutputPin]to properties/fields - Add
[NodeExecute]to the execution method - Register with
AttributeBasedNodeFactory.RegisterNodeType<T>()
Changes affect all consumers. Test with all example applications.
- Tabs for indentation (not spaces)
- File-scoped namespaces with using directives inside
- Explicit types - no
var - No
this.qualifier - Always use braces for control flow
- Primary constructors when appropriate
All C# files require this header:
// Copyright (c) ktsu.dev
// All rights reserved.
// Licensed under the MIT license.The iOS target framework is opt-in. ImGui.App, ImGuiAppDemo.iOS and ImGui.App.iOS.SmokeTest
add their net10.0-ios head only when IncludeIosTargets is true and the host is macOS;
otherwise ImGui.App cross-targets net10.0;net9.0;net8.0 and the two app projects degrade to a
plain net10.0 console exe. Only .github/workflows/ios.yml opts in, at job level, and it is the
only workflow that runs dotnet workload install ios. Without the gate, every macOS build widened
itself to net10.0-ios and failed with NETSDK1147 before reaching a test, which is why macOS was
excluded from the test matrix (#327). If you add a step that has to build the iOS head, set
IncludeIosTargets — an environment variable works, MSBuild reads it as a property.
The test matrix in dotnet.yml fans out over Linux, Windows and macOS. The six UI suites run on
Linux only: they are the whole cost of the job, and the CPU rasterizer they drive measures the same
on either host. The Test step tests for Linux rather than against Windows, so any platform added
later gets that cheap treatment by default.
Uses scripts/PSBuild.psm1 PowerShell module for CI pipeline. Version increments are controlled by commit message tags: [major], [minor], [patch], [pre]. Auto-generated files (VERSION.md, CHANGELOG.md, LICENSE.md) should not be manually edited. CI runs on Windows, publishes to NuGet, uses SonarQube for analysis.
Do not add global suppressions for warnings. Use explicit suppression attributes with justifications when needed, with preprocessor defines only as fallback. Make the smallest, most targeted suppressions possible.