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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This is the **ktsu ImGui Suite**, a collection of .NET libraries for building De
- **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` (`Srgb` → `ImColor`/`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
- **ImGui.NodeEditor** (`ktsu.ImGui.NodeEditor`) - ImNodes-based visual node editor with `NodeEditorEngine`, `AttributeBasedNodeFactory`, physics-based layout, `NodeEditorRenderer`, `NodeEditorInputHandler`. 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
- **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
- **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](#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.
Expand Down
1 change: 1 addition & 0 deletions ImGui.NodeEditor/ImGui.NodeEditor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,6 @@
<ItemGroup>
<ProjectReference Include="..\NodeGraph\NodeGraph.csproj" />
<ProjectReference Include="..\ForceDirectedLayout\ForceDirectedLayout.csproj" />
<ProjectReference Include="..\ImGui.Probes\ImGui.Probes.csproj" />
</ItemGroup>
</Project>
362 changes: 362 additions & 0 deletions ImGui.NodeEditor/PhysicsSettingsPanel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.ImGui.NodeEditor;

using System.Globalization;

using Hexa.NET.ImGui;

using ktsu.ForceDirectedLayout;
using ktsu.ImGui.Probes;

/// <summary>
/// Draws every knob the layout simulation has, so a graph can be tuned while it is on screen.
/// </summary>
/// <remarks>
/// The forces interact, and none of them can be judged on its own: raising repulsion changes what
/// the spring's rest length means, and levelling links only works in the room repulsion made. So the
/// panel exposes the whole of <see cref="PhysicsSettings"/> rather than a chosen subset, grouped by
/// the force each setting belongs to and captioned with what moving it does. A setting that is not
/// here is a setting nobody can reach without recompiling.
/// <para>
/// Every control marks itself with <see cref="ImGuiProbes"/>, so a UI test can address it by the
/// label the user sees.
/// </para>
/// </remarks>
public static class PhysicsSettingsPanel
{
/// <summary>The value every "Reset" restores, and what the sliders are ranged around.</summary>
private static readonly PhysicsSettings Defaults = new();

/// <summary>
/// Draws the whole panel.
/// </summary>
/// <param name="settings">The settings to edit; replaced when the user changes anything.</param>
/// <returns>True when <paramref name="settings"/> was changed by this frame's input.</returns>
public static bool Draw(ref PhysicsSettings settings)
{
Ensure.NotNull(settings);

bool changed = false;
PhysicsSettings working = settings;

bool enabled = working.Enabled;
if (Checkbox("Run simulation", ref enabled))
{
working = working with { Enabled = enabled };
changed = true;
}

ImGui.SameLine();
if (Button("Reset all"))
{
// The running flag is the user's, not the layout's, so a reset of the tuning leaves it be.
working = Defaults with { Enabled = working.Enabled };
changed = true;
}

changed |= DrawRepulsion(ref working);
changed |= DrawSprings(ref working);
changed |= DrawLinkShaping(ref working);
changed |= DrawGravity(ref working);
changed |= DrawOverlap(ref working);
changed |= DrawIntegration(ref working);

if (changed)
{
settings = working;
}

return changed;
}

/// <summary>
/// Draws what the simulation is doing right now, which is what says whether a setting helped.
/// </summary>
/// <param name="engine">The engine being tuned.</param>
/// <remarks>
/// Energy is the honest read on a tuning change: a graph that will not settle shows it here long
/// before the eye catches the drift, and a graph that settles instantly was probably damped into
/// stillness before it finished arranging itself.
/// </remarks>
public static void DrawDiagnostics(NodeEditorEngine engine)
{
Ensure.NotNull(engine);

(int substeps, float substepDelta) = engine.LastPhysicsStepInfo;

Text("Energy", engine.TotalSystemEnergy.ToString("F1", CultureInfo.CurrentCulture));
Text("Settled", engine.IsStable ? "yes" : "no");
Text("Substeps", substeps.ToString(CultureInfo.CurrentCulture));
Text("Substep", $"{substepDelta * 1000f:F2} ms");
Text("Bodies", $"{engine.Nodes.Count} nodes, {engine.Links.Count} links");
}

private static bool DrawRepulsion(ref PhysicsSettings settings)
{
if (!Header("Repulsion"))
{
return false;
}

bool changed = false;
double repulsion = settings.RepulsionStrength;
if (Slider("Repulsion strength", ref repulsion, 0.0, 50_000_000.0, "%.0f",
"Pushes every pair of nodes apart, by the inverse square of their distance. This is what makes the room the other forces arrange things in; with none, a graph collapses onto itself."))
{
settings = settings with { RepulsionStrength = repulsion };
changed = true;
}

double minDistance = settings.MinRepulsionDistance;
if (Slider("Minimum distance", ref minDistance, 1.0, 200.0, "%.0f px",

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.

Check warning on line 112 in ImGui.NodeEditor/PhysicsSettingsPanel.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Define a constant instead of using this literal '%.0f px' 5 times.
"The distance repulsion stops getting stronger at. Without a floor, two nodes that nearly touch would be flung apart."))
{
settings = settings with { MinRepulsionDistance = minDistance };
changed = true;
}

return changed;
}

private static bool DrawSprings(ref PhysicsSettings settings)
{
if (!Header("Link springs"))
{
return false;
}

bool changed = false;
double spring = settings.LinkSpringStrength;
if (Slider("Spring strength", ref spring, 0.0, 3.0, "%.2f",
"How hard a link pulls its two ends toward the rest length. Raise it to tighten clusters, lower it to let repulsion spread them."))
{
settings = settings with { LinkSpringStrength = spring };
changed = true;
}

double rest = settings.RestLinkLength;
if (Slider("Rest length", ref rest, 50.0, 600.0, "%.0f px",
"The distance a link is happy at, measured between the pins it actually joins. This sets the horizontal step between one level of the graph and the next."))
{
settings = settings with { RestLinkLength = rest };
changed = true;
}

double bias = settings.DirectionalBias;
if (Slider("Left-to-right bias", ref bias, 0.0, 3.0, "%.2f",
"Pushes each link's source left of its target. A link running backwards is reordered, and the pair is allowed past each other vertically while it happens."))
{
settings = settings with { DirectionalBias = bias };
changed = true;
}

return changed;
}

private static bool DrawLinkShaping(ref PhysicsSettings settings)
{
if (!Header("Link shaping"))
{
return false;
}

bool changed = false;
double flattening = settings.LinkFlatteningStrength;
if (Slider("Flattening", ref flattening, 0.0, 3.0, "%.2f",
"Levels each link's two ends, and splays them apart horizontally when the drawn curve would otherwise double back behind its own nodes and disappear."))
{
settings = settings with { LinkFlatteningStrength = flattening };
changed = true;
}

double margin = settings.LinkFlatteningMargin;
if (Slider("Flattening margin", ref margin, 0.0, 200.0, "%.0f px",
"Extra horizontal room demanded on top of the clearance the curve geometry needs. Raise it if links still graze the nodes they leave."))
{
settings = settings with { LinkFlatteningMargin = margin };
changed = true;
}

double untwist = settings.LinkUntwistStrength;
if (Slider("Untwisting", ref untwist, 0.0, 1.0, "%.3f",
"Swaps two links that meet at one node into the order of the pins they arrive at, so they stop crossing. Stronger settings untangle more and drive more nodes across links they are not part of."))
{
settings = settings with { LinkUntwistStrength = untwist };
changed = true;
}

return changed;
}

private static bool DrawGravity(ref PhysicsSettings settings)
{
if (!Header("Gravity"))
{
return false;
}

bool changed = false;
double gravity = settings.GravityStrength;
if (Slider("Gravity strength", ref gravity, 0.0, 400.0, "%.0f",
"Pulls every node toward the gravity target, which is what stops repulsion pushing a graph apart for ever."))
{
settings = settings with { GravityStrength = gravity };
changed = true;
}

double anchor = settings.OriginAnchorWeight;
if (Slider("Origin anchor", ref anchor, 0.0, 1.0, "%.2f",
"Where that target sits: 0 is the graph's own centre, so it may drift; 1 is the fixed world origin, so it stays put."))
{
settings = settings with { OriginAnchorWeight = anchor };
changed = true;
}

return changed;
}

private static bool DrawOverlap(ref PhysicsSettings settings)
{
if (!Header("Overlap"))
{
return false;
}

bool changed = false;
double margin = settings.OverlapMargin;
if (Slider("Clearance", ref margin, 0.0, 200.0, "%.0f px",
"The gap kept between two node rectangles. At zero the pass is off and the forces alone decide, which leaves nodes drawn over each other."))
{
settings = settings with { OverlapMargin = margin };
changed = true;
}

double correction = settings.MaxOverlapCorrection;
if (Slider("Maximum correction", ref correction, 1.0, 200.0, "%.0f px",
"How far a pair may be pushed apart in one substep. Lower values make a deep overlap slide apart over several frames rather than snap."))
{
settings = settings with { MaxOverlapCorrection = correction };
changed = true;
}

return changed;
}

private static bool DrawIntegration(ref PhysicsSettings settings)
{
if (!Header("Motion and limits"))
{
return false;
}

bool changed = false;
double damping = settings.DampingFactor;
if (Slider("Damping", ref damping, 0.01, 0.99, "%.2f",
"The fraction of a node's speed kept each second. Low values settle quickly but can stop a graph before it has finished arranging itself."))
{
settings = settings with { DampingFactor = damping };
changed = true;
}

double maxForce = settings.MaxForce;
if (Slider("Maximum force", ref maxForce, 100.0, 50_000.0, "%.0f",
"The cap on the total force one node may feel, which is what keeps two nearly coincident nodes from exploding."))
{
settings = settings with { MaxForce = maxForce };
changed = true;
}

double maxVelocity = settings.MaxVelocity;
if (Slider("Maximum speed", ref maxVelocity, 5.0, 1000.0, "%.0f px/s",
"The cap on how fast a node may travel. It bounds how long a graph takes to settle, since a node cannot reach its place faster than this."))
{
settings = settings with { MaxVelocity = maxVelocity };
changed = true;
}

double hz = settings.TargetPhysicsHz;
if (Slider("Substep rate", ref hz, 30.0, 480.0, "%.0f Hz",
"How finely each frame is subdivided, independent of the frame rate. Higher is steadier and costs more."))
{
settings = settings with { TargetPhysicsHz = hz };
changed = true;
}

double stability = settings.StabilityThreshold;
if (Slider("Settled below", ref stability, 0.0, 100.0, "%.1f",
"The total energy under which the graph is reported settled. It changes what is reported, not how anything moves."))
{
settings = settings with { StabilityThreshold = stability };
changed = true;
}

return changed;
}

/// <summary>
/// Submits one labelled slider over a double, with its explanation on hover.
/// </summary>
/// <remarks>
/// ImGui edits floats, and every one of these settings is a double, so each is narrowed for the
/// drag and widened back. The write only happens when the slider reports a change, so a setting
/// the user never touches keeps its full precision rather than being rounded by being looked at.
/// </remarks>
private static bool Slider(string label, ref double value, double min, double max, string format, string help)
{
float editing = (float)value;
bool changed = ImGui.SliderFloat(label, ref editing, (float)min, (float)max, format);
ImGuiProbes.MarkItem(label);
Explain(help);

if (!changed)
{
return false;
}

value = editing;
return true;
}

private static bool Checkbox(string label, ref bool value)
{
bool changed = ImGui.Checkbox(label, ref value);
ImGuiProbes.MarkItem(label);
return changed;
}

private static bool Button(string label)
{
bool clicked = ImGui.Button(label);
ImGuiProbes.MarkItem(label);
return clicked;
}

private static bool Header(string label)
{
bool open = ImGui.CollapsingHeader(label);
ImGuiProbes.MarkItem(label);
return open;
}

private static void Text(string label, string value)
{
ImGui.TextUnformatted($"{label}: {value}");
ImGuiProbes.MarkItem(label);
}

/// <summary>Attaches the previous item's explanation, wrapped to a readable column.</summary>
private static void Explain(string help)
{
if (!ImGui.IsItemHovered())
{
return;
}

ImGui.BeginTooltip();
ImGui.PushTextWrapPos(ImGui.GetFontSize() * 28f);
ImGui.TextUnformatted(help);
ImGui.PopTextWrapPos();
ImGui.EndTooltip();
}
}
Loading