Skip to content

Latest commit

 

History

History
871 lines (716 loc) · 67.4 KB

File metadata and controls

871 lines (716 loc) · 67.4 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Build Commands

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 configuration

Project Structure

This 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.0 for libraries, net10.0 for tests

Libraries

  • 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 (RadialProgressBar with RadialCountdown/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 to Hexa.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. DatePicker and FileTreeView need a Material Icons font registered via FontHelper.AddCustomFont(io, data, size, FontHelper.GetMaterialIconRanges(), mergeWithPrevious: true) (not ImGuiAppConfig.Fonts, which applies the Nerd Font mapping); see examples/ImGuiAppDemo. YearPicker needs no icon font. OpenFileDialog, SaveFileDialog and OpenFolderDialog need the same Material Icons font, for their toolbar, breadcrumb and file-tree glyphs; RenameDialog, DialogMessageBox and ShowMessageBox need none. DockedWindow composes Hexa's ImWindow internally rather than inheriting it — subclass it, override Title and DrawContent(), then call Show()/Close(). All of the dialogs and DockedWindow require 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 between ktsu.Semantics.Color and ImGui. Colors are held as the semantic Color (linear) and Srgb types and converted only at the ImGui seam: ColorImGuiExtensions (ToImColor/FromImColor, ToImGuiVector4, ToImGuiU32) and SrgbImGuiExtensions (SrgbImColor/ImGuiVector4/ImU32, packed directly with no linear round-trip). The ImColor and Srgb ToImGuiU32 apply the global style alpha like ImGui.GetColorU32; the linear Color.ToImGuiU32 is a pure pack matching ColorConvertFloat4ToU32. ImColor extension 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 to ktsu.Semantics.Color. (There is no ImColor factory class — construct via Color/Srgb and 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 in ImGui.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 one LayoutCore: a generic facade over your own types, an id-based ForceLayout for bulk POD submission, and the flat core. Also published as a Native AOT shared library with a C ABI. ImGui.NodeEditor is one consumer.
  • ImGui.NodeEditor (ktsu.ImGui.NodeEditor) - ImNodes-based visual node editor with NodeEditorEngine, AttributeBasedNodeFactory, physics-based layout, NodeEditorRenderer, NodeEditorInputHandler. PhysicsSettingsPanel.Draw(ref PhysicsSettings) draws every layout setting grouped by force and captioned, and DrawDiagnostics(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, so NodeEditorRenderer.Zoom supplies one and FitToView centres 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, and DrawHoveredLinkOnTop (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.AllowsMultipleConnections defaults to many for an output and one for an input, [InputPin(AllowMultipleConnections = true)] / [OutputPin(AllowMultipleConnections = false)] override it through the factory, and NodeEditorEngine.SetPinAllowsMultipleConnections sets it directly. GetOutgoingLinks, GetIncomingLinks, GetDownstream and GetUpstream walk the graph
  • ImGui.Markdown (ktsu.ImGui.Markdown) - CommonMark markdown renderer built on Markdig (pipe tables, task lists, autolinks), layered on ImGui.Color only, with no dependency on ImGui.App. Static ImGuiMarkdown.Render(string, MarkdownConfig?) parses with an internal source-keyed cache; MarkdownDocument parses once for hot render paths. MarkdownConfig exposes FontResolver, OnLinkClicked, ImageResolver, HeadingScales, WrapWidth, ListIndentPixels, ParagraphSpacingPixels, and LinkColor. Heading sizes derive from the live font size, so DPI and ImGuiApp.GlobalScale are respected automatically. Bold/italic use real glyphs when the host app registers named font variants via FontResolver, otherwise faux styling (faux-bold double-draw, faux-italic renders upright). Fenced and indented code blocks go to MarkdownConfig.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.SyntaxHighlighting plugs 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 classified HighlightedLine/HighlightedToken runs; SyntaxHighlighter.HighlightCached goes through a bounded cache keyed by source, language and tab width; HighlightedCode tokenizes once for hot render paths. Languages are data (LanguageDefinition: line/block comment, string, keyword, type, constant, operator, identifier and embedded-language rules) held in LanguageRegistry, which resolves names and aliases case-insensitively and falls back to plain text for unknown names rather than throwing. Fifteen built-ins in BuiltInLanguages: text, csharp, c, cpp, javascript, typescript, python, json, yaml, xml, html, css, sql, shell, lua. Two tokenizers back them — the general CodeTokenizer, and MarkupTokenizer for definitions with IsMarkup (XML/HTML), which classify structurally rather than by keyword. SyntaxTheme holds one ktsu.Semantics.Color.Color per TokenKind, with Dark/Light built in and Background/Plain/LineNumber left 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 over ktsu.SyntaxHighlighting, layered on ImGui.Color only, with no dependency on ImGui.App. Static ImGuiSyntaxHighlighting.Render(code, language, SyntaxHighlightConfig?) tokenizes through the shared cache and draws; Render(HighlightedCode, config) draws pre-tokenized code, and HighlightedCodeExtensions re-adds code.Render(config) as an extension since the tokenized type itself knows nothing about ImGui. Highlight forwards to SyntaxHighlighter.Highlight. Leaving SyntaxHighlightConfig.Theme null picks between SyntaxTheme.Dark/Light per frame from the window background's luminance, and unset Background/Plain/LineNumber come from FrameBg/Text/TextDisabled. Code is never wrapped, and there is no scrolling, selection or editing. ImGui.Markdown's CodeBlockRenderer plugs into this, and neither library references the other.

Examples

  • examples/ImGuiAppDemo/ - Main application demo
  • examples/ImGuiWidgetsDemo/ - Widget showcase
  • examples/ImGuiStylerDemo/ - Theme gallery
  • examples/ImGuiPopupsDemo/ - Popup demonstrations
  • examples/ImGuiMarkdownDemo/ - Markdown rendering demo
  • examples/ImGuiSyntaxHighlightingDemo/ - Syntax highlighting demo, including markdown code blocks routed through the highlighter

Tests

  • tests/ImGui.App.Tests/ - App framework tests with mock OpenGL provider, plus Images/ covering the PNG, JPEG, BMP and TGA decoders and the resampler. TestImageBuilder encodes 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 in JpegDecoderTests.
  • tests/ForceDirectedLayout.Tests/ - The layout simulation: per-force unit tests, the overlap pass, repulsion, and Bench/ — the benchmark harness every layout claim is measured with. See Layout benchmarking below.
  • tests/NodeGraph.Tests/ - Node graph attribute and type utility tests
  • tests/ImGui.NodeEditor.Tests/ - Engine, factory and rendering tests for the node editor. The engine and factory ones need no context; NodeRenderingTests, ZoomTests and HoverHighlightTests drive real frames through ImGuiAppHarness, since zoom and hover are made of what the renderer writes into ImNodes and reads back out, and neither exists without drawing
  • tests/<Demo>.UITests/ - One headless UI test project per example, driving the demo's real BuildConfig() through ImGuiAppHarness: 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 against ktsu.SyntaxHighlighting, which has no ImGui dependency at all; the ImGui drawing layer is covered by ImGuiSyntaxHighlightingDemo.UITests.

Key Files

  • ImGui.App/ImGuiApp.cs - Main static entry point (ImGuiApp.Start(), ImGuiApp.Stop())
  • ImGui.App/ImGuiAppConfig.cs - Application configuration record
  • ImGui.App/PidFrameLimiter.cs - PID-controlled frame rate limiter with auto-tuning
  • ImGui.App/FontMemoryGuard.cs - GPU memory management for font atlases
  • ImGui.App/FontHelper.cs - Unicode, emoji, and Nerd Font character range support
  • ImGui.App/ForceDpiAware.cs - Multi-platform DPI detection
  • ImGui.App/WindowingEnvironment.cs - Wayland / tiling window manager detection driving ImGuiAppConfig.WindowGeometry
  • ImGui.App/ImGuiExtensionManager.cs - Auto-detection of ImGuizmo, ImNodes, ImPlot
  • ImGui.App/Images/ImageDecoder.cs - Front door for image loading; sniffs the format from the file's own bytes
  • ImGui.App/Images/ImagePixels.cs - The decoded RGBA8 buffer every decoder produces and the texture cache uploads
  • ImGui.App/Images/PngDecoder.cs - PNG, including every colour type and bit depth, tRNS, Adam7 and all five filters
  • ImGui.App/Images/JpegDecoder.cs - Baseline, extended sequential and progressive Huffman JPEG
  • ImGui.App/Images/BmpDecoder.cs - BMP: core and info headers, 1/4/8/16/24/32 bit, BI_RGB and BI_BITFIELDS
  • ImGui.App/Images/TgaDecoder.cs - TGA: colour-mapped, true-colour and greyscale, raw and run-length encoded
  • ImGui.App/Images/ImageResampler.cs - Separable Lanczos-3 scaling on premultiplied alpha, behind SetWindowIcon
  • ImGui.Widgets/PropertyGrid.cs - Two-column property grid: options, the path-browse request, and the table/row plumbing (PropertyGridRows.cs holds the typed rows, PropertyGridLists.cs the list rows). See Property grid below
  • ImGui.Widgets/DividerZone.cs - Resizable split pane layout
  • ImGui.Widgets/TabPanel.cs - Tabbed interface with drag-and-drop
  • ImGui.Widgets/FlameGraph.cs - Hexa-backed flame graph with managed sample marshaling
  • ImGui.Widgets/Splitter.cs - Hexa-backed horizontal/vertical draggable splitters
  • ImGui.Widgets/DeferredDrawing.cs - DrawDeferred()/DrawDeferredDocked() per-frame pumps, and the ToggleSwitch animation-clock fallback used when neither has ever run
  • ImGui.Widgets/DockedWindow.cs - Abstract base for windows drawn by DrawDeferredDocked(); composes Hexa's ImWindow via a private adapter instead of inheriting it
  • ImGui.Widgets/Dialogs/ - Hexa-backed dialog wrappers: FileDialogs.cs (OpenFileDialog/SaveFileDialog/OpenFolderDialog), RenameDialog.cs, MessageDialogs.cs (DialogMessageBox/ShowMessageBox), DialogOutcome.cs (shared DialogOutcome enum and result mapping)
  • SyntaxHighlighting/SyntaxHighlighter.cs - The renderer-agnostic entry point (Highlight, HighlightCached)
  • SyntaxHighlighting/Tokenizing/CodeTokenizer.cs - Single-pass lexer driven by a LanguageDefinition; emits tokens over the whole source, so block comments and multi-line strings stay whole
  • SyntaxHighlighting/Tokenizing/LineSplitter.cs - Cuts those tokens into lines, normalizes CRLF, and expands tabs against the column they start at
  • SyntaxHighlighting/Tokenizing/EmbeddedExpander.cs - Replaces a comment or string token with the token run of the language written inside it, and reads lang= hint comments
  • SyntaxHighlighting/Languages/BuiltInLanguages.cs - The fifteen built-in language definitions
  • SyntaxHighlighting/Languages/EmbeddedContent.cs - The recognizers behind the embedded-language rules (LooksLikeJson parses, it does not pattern match)
  • ImGui.SyntaxHighlighting/Rendering/CodeRenderer.cs - Draws the background, gutter and colored token runs, then reserves the footprint as one item
  • ImGui.Color/ColorImGuiExtensions.cs - Color ↔ ImColor/ImU32/Vector4 conversions (ImColor.ToImGuiU32 applies global alpha; Color.ToImGuiU32 is pure)
  • ImGui.Color/SrgbImGuiExtensions.cs - Direct Srgb → ImColor/ImGuiVector4/ImU32 conversions (no linear round-trip)
  • ImGui.Color/ImColorExtensions.cs - ImColor adjustment, analysis, and contrast operations
  • ImGui.Styler/Palette.cs - Theme-aware color palette (Palette.Basic, Palette.Semantic, Palette.Neutral, …) and theme color lookups
  • ImGui.Styler/Theme.cs - Theme management, browser, and selector
  • ImGui.Styler/ScopedColor.cs - RAII-pattern color styling (ImColor/Color/Srgb overloads; ScopedTextColor too)
  • NodeGraph/NodeAttribute.cs - Core node attributes
  • NodeGraph/PinAttribute.cs - Pin declaration attributes
  • ImGui.NodeEditor/NodeEditorEngine.cs - Node graph business logic

Dependencies

  • 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.Scripting and Hexa.NET.Math into 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

Architecture

Static Entry Points with Nested Classes

Each library exposes a static class as its main entry point, with nested public classes for components:

  • ImGuiApp.Start() / ImGuiApp.Stop() - Application lifecycle
  • ImGuiWidgets.SearchBox(), ImGuiWidgets.Knob(), ImGuiWidgets.Combo(), ImGuiWidgets.RadialProgressBar() - Widget methods
  • new ImGuiWidgets.TabPanel(), new ImGuiWidgets.DividerContainer() - Widget instances
  • new ImGuiPopups.InputString(), new ImGuiPopups.FilesystemBrowser() - Popup instances
  • Theme.Apply(), Theme.ShowThemeSelector() - Theme management
  • Button.Alignment.Center(), Text.Color.Error(), Indent.ByDefault() - Styling utilities

Configuration Pattern

ImGuiApp.Start(new ImGuiAppConfig
{
    Title = "App",
    OnRender = delta => { /* render code */ },
    OnStart = () => { /* init code */ },
    PerformanceSettings = new() { FocusedFps = 60.0 }
});

Font Configuration

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.

Image decoding

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_RGB leaves 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.

Deferred Drawing (dialogs and docked windows)

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 registered DockedWindow. It requires ImGuiConfigFlags.DockingEnable, which is set by ImGuiAppConfig.EnableDocking = true; without it the pump throws InvalidOperationException. The flag cannot be turned on from the pump: ImGui only accepts it before the first NewFrame(), and changing it mid-frame aborts the process on the next frame with "Please set DockingEnable before the first call to NewFrame()!". Hexa's WidgetManager.Draw() calls DockSpaceOverViewport without checking the flag, so without it the dockspace would silently do nothing anyway. Internally it does everything DrawDeferred() does (via Hexa's WidgetManager.Draw(), which itself calls Hexa's DialogManager.Draw(), MessageBoxes.Draw(), PopupManager.Draw() and AnimationManager.Tick()), so DrawDeferred() must not also be called. DockedWindow only renders under this pump — under DrawDeferred() a DockedWindow is registered but never drawn, because Hexa's widget manager is only driven by that pump. A DockedWindow is 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()).

Property grid

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. Section returns a SectionScope disposable rather than a bool, and rows are written inside its using with no test around them: while the section is collapsed the scope raises the grid's suppressDepth, 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.IsOpen is 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.Widgets has 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 raises PropertyGridOptions.OnBrowse with a PropertyPathRequest, and because a dialog outlives the frame that opened it, the request carries no reference to the value: Complete records 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 asks PropertyGridOptions.ThumbnailResolver for 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 checks File.Exists first.

  • 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 by PropertyGridTests; keep the label in the id.

  • List elements are labelled [i] inside a ScopedId for 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 to ItemProbe, so tests address elements as Tags/[0] and their remove buttons as Tags/[0]/remove.

Hexa-backed vs ktsu widgets

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.

CurveTrack, and why it is not one of the curve editors

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. CurveTrackState holds the interaction — picking, dragging, adding, removing — with no ImGui dependency, tested without a context, exactly as HandleTrackState is.
  • It overlays a rectangle rather than reserving one, like HandleTrack and for the same reason: it is designed to sit on a Histogram drawn into the same rectangle. It paints no background and restores the cursor, so a caller with nothing underneath reserves the box with a degenerate Histogram call, which is defined to draw exactly the empty frame. Because it restores the cursor, ImGui asserts if a window's last act is a CurveTrack and nothing follows it — submit something afterwards, as any real layout already does.

Callback-driven editors

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.SequenceSource for a timeline: implement FrameMin, FrameMax, ItemCount, GetItem(int), and SetItemRange(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, BeginEdit and EndEdit are virtual with no-op/empty defaults.
  • Subclass ImGuiWidgets.CurveSource for a multi-curve graph: implement CurveCount, ViewMin, ViewMax, GetPointCount(int), GetPoints(int), GetCurveColor(int), EditPoint(int, int, Vector2) and AddPoint(int, Vector2). IsVisible, GetInterpolation, BackgroundColor, BeginEdit and EndEdit are 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.

Embedded languages

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.LooksLikeJson parses — 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 is EmbeddedHosts.StringLiteral only, so "Update the cache and carry on" in a comment is never a query.
  • Unclassified embedded text keeps its host's kind. A TokenKind.Plain token from the inner tokenizer is re-emitted as Comment, DocComment or StringLiteral, 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, and EmbeddedExpander verifies 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.

Scoped Styling (RAII Pattern)

// 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));
}

Node Graph Architecture

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 types
    • NodeEditorRenderer - Pure ImNodes rendering, and the view: Zoom and FitToView
    • NodeEditorInputHandler - 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 FitToView moves the nodes. Zoom is applied on the way into ImNodes and undone on the way back out, so nothing zoomed ever reaches the engine.

Hover highlighting

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 DrawHoveredLinkOnTop draws 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. RenderDebugOverlays draws 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.

Key Technical Details

  • PID frame limiter with auto-tuning (Coarse/Fine/Precision phases)
  • Throttled rendering: Different FPS for focused/unfocused/idle/minimized states
  • Font memory management via FontMemoryGuard with 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

Testing

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 class

Do 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.

Layout benchmarking

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. Counter is a real twenty-node document with sizes running from a 60-wide literal to a 118x180 function; TwoClasses is 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; Chain is the shape that most wants to be a horizontal row; FanIn is eight sources arriving at eight pins on one target, which is where crossings come from; MixedSizes alternates 400-wide slabs with 50-wide literals; Disconnected is 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 all to 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) and IDE0055 (formatting) are errors here, so a quick { s.X = v; return s; } lambda will not build. Use a with expression, 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.

Tuned defaults

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.
  • MinRepulsionDistance does nothing below sqrt(RepulsionStrength / MaxForce), which is about 13 under these defaults. MaxForce caps 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 below MaxForce and 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 ForceLayoutTests broke 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.

Placement is not cohesion

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.

Demo UI tests

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, with InternalsVisibleTo for its UITests assembly) so a test drives the real configuration rather than a copy, plus ResetState() because demo state is static and outlives the harness.
  • Use WasSeenInFrame, not Rect, to ask "is this on screen now". ItemProbe.Rect returns 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 as IsVisible(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.Widgets and ktsu.ImGui.Popups mark items themselves; the plain ImGui buttons, headers, tabs and sliders the demos draw go through a small DemoProbe helper (or a local DemoButton/DemoHeader/DemoTab) that calls ImGuiProbes.MarkItem right 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 Rect throws rather than guessing, so qualify it (harness.Probe.Matches("Username").Single(n => n.Contains("FormExample"))) or pick a distinct one.

Widget UI tests

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 with BoundsOfDifference against a frame drawn without the widget.
  • Widgets that submit several items, or none, need MarkSpan. PinInput is a row of separate text boxes; BufferingBar paints 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 Click renders a further frame after the release, so capture with clicked |= 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 around EvaluateFallbackTick instead.
  • 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.

Adding Components

New Widget

  1. Add class to ImGui.Widgets/
  2. Follow existing widget patterns (static methods or instance classes)
  3. Add demo to examples/ImGuiWidgetsDemo/
  4. Add an isolation suite to tests/ImGui.Widgets.UITests/

New Language (Syntax Highlighting)

  1. Add a LanguageDefinition to SyntaxHighlighting/Languages/BuiltInLanguages.cs and list it in All
  2. Reuse the shared comment/string rule fields rather than re-declaring equivalent rules
  3. Set EmbeddedLanguages to BuiltInEmbeddedRules.Default for a programming language, or .Data for a data format (no SQL rule); leave it empty only when the language has no comments or strings worth looking in
  4. Add tokenizer tests to tests/SyntaxHighlighting.Tests/CodeTokenizerTests.cs
  5. Add a snippet to examples/ImGuiSyntaxHighlightingDemo/ if the language shows off something new

New Embedded-Language Rule

  1. Add the recognizer to SyntaxHighlighting/Languages/EmbeddedContent.cs — strict enough that prose is rejected, since a false positive recolors text that is not code
  2. Add the EmbeddedLanguageRule to BuiltInEmbeddedRules and list it in Default/Data as appropriate
  3. Choose Hosts deliberately: a rule whose recognizer could fire on English belongs in EmbeddedHosts.StringLiteral only, the way the SQL rule does
  4. Add tests to tests/SyntaxHighlighting.Tests/EmbeddedLanguageTests.cs, including a negative one

New Theme

  1. Add theme definition to ImGui.Styler/
  2. Test in examples/ImGuiStylerDemo/

New Node Type

  1. Decorate a class/struct with [Node] attribute
  2. Add [InputPin] / [OutputPin] to properties/fields
  3. Add [NodeExecute] to the execution method
  4. Register with AttributeBasedNodeFactory.RegisterNodeType<T>()

Modifying ImGui.App

Changes affect all consumers. Test with all example applications.

Code Style

  • 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.

CI/CD

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.

Code Quality

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.