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
5 changes: 5 additions & 0 deletions Coder.Editor/Coder.Editor.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@
<PackageReference Include="ktsu.ImGui.App" />
<!-- The preview pane draws the generated source highlighted rather than as flat text. -->
<PackageReference Include="ktsu.ImGui.SyntaxHighlighting" />
<!-- The panes are divider containers, so the user sizes them rather than the application. -->
<PackageReference Include="ktsu.ImGui.Widgets" />
<!-- Remembering whether the window was maximized means naming WindowState, which lives here
rather than in the Silk.NET.Windowing facade beside it. -->
<PackageReference Include="Silk.NET.Windowing.Common" />
<PackageReference Include="ktsu.UndoRedo.Core" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
Expand Down
124 changes: 108 additions & 16 deletions Coder.Editor/CoderEditorApp.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
using ktsu.Coder.Languages;
using ktsu.ImGui.App;
using ktsu.ImGui.SyntaxHighlighting;
using ktsu.ImGui.Widgets;
using Silk.NET.Windowing;

/// <summary>
/// The editor: a graph of the document on the left, the code it generates on the right.
Expand Down Expand Up @@ -41,7 +43,7 @@
/// <summary>
/// Gets the editor over the current document.
/// </summary>
public AstGraphEditor Editor { get; private set; } = new(NewDocument());
public AstGraphEditor Editor { get; private set; } = EditorFor(NewDocument());

/// <summary>
/// Gets the file the document was last read from or written to, or null for an unsaved one.
Expand Down Expand Up @@ -78,13 +80,53 @@
OnStart = () => Editor.LayoutRunning = Settings.LayoutRunning,
OnRender = Draw,

// Opened where it was left. The window is part of what a user arranges, and an application
// that forgets it is one they rearrange on every run.
InitialWindowState = new ImGuiAppWindowState
{
Size = new Vector2(Settings.WindowWidth, Settings.WindowHeight),
Pos = new Vector2(Settings.WindowX, Settings.WindowY),
LayoutState = Settings.WindowMaximized ? WindowState.Maximized : WindowState.Normal,
},
OnMoveOrResize = RememberWindow,

// The menu goes here rather than inside OnRender: ImGuiApp's main window carries no
// ImGuiWindowFlags.MenuBar, so an ImGui.BeginMenuBar() call in the render delegate always
// returns false and the menu silently never appears. OnAppMenu runs inside the application's
// own BeginMainMenuBar.
OnAppMenu = DrawMenu,
};

/// <summary>
/// Notes where the window is, so the next run opens there.
/// </summary>
/// <remarks>
/// The size and position read back are the ones the window has when it is not maximized, which is
/// what should be restored when a maximized window is un-maximized. Settings are written to disk
/// when the application exits, so this only has to keep them current.
/// </remarks>
private void RememberWindow()
{
ImGuiAppWindowState state = ImGuiApp.WindowState;

Settings.WindowWidth = state.Size.X;
Settings.WindowHeight = state.Size.Y;
Settings.WindowX = state.Pos.X;
Settings.WindowY = state.Pos.Y;
Settings.WindowMaximized = state.LayoutState == WindowState.Maximized;
}

/// <summary>
/// Builds an editor over a document, set up the way this application hosts one.
/// </summary>
/// <param name="document">The document to edit.</param>
/// <returns>The editor.</returns>
/// <remarks>
/// The properties panel is one of this application's own panes, so the editor is told not to draw
/// a second one of its own beside the canvas.
/// </remarks>
private static AstGraphEditor EditorFor(AstNode document) => new(document) { ShowInspector = false };

/// <summary>
/// Builds the document a fresh editor opens with.
/// </summary>
Expand Down Expand Up @@ -126,19 +168,72 @@
HandleShortcuts();

Vector2 available = ImGui.GetContentRegionAvail();
float graphWidth = available.X * 0.62f;

ImGui.BeginChild("graph-pane", new Vector2(graphWidth, available.Y - StatusBarHeight));
Editor.Draw(new Vector2(graphWidth - PaneInset, available.Y - StatusBarHeight - PaneInset), deltaTime);
// The container measures itself from the remaining content region, so it is given a child of
// the height that is actually the panes' — otherwise it would take the status bar's row too
// and draw the bar over its own bottom edge.
ImGui.BeginChild("panes", new Vector2(0, available.Y - StatusBarHeight));
Panes.Tick(deltaTime);
ImGui.EndChild();

ImGui.SameLine();
DrawStatusBar();
}

ImGui.BeginChild("code-pane", new Vector2(0, available.Y - StatusBarHeight));
DrawCodePane();
ImGui.EndChild();
/// <summary>
/// Gets the pane layout, built on first use.
/// </summary>
/// <remarks>
/// A divider container holds the sizes the user has dragged the panes to, so it has to outlive
/// the frame. It is built lazily rather than in a field initializer because its zones call back
/// into this instance, which a field initializer cannot refer to.
/// </remarks>
private ImGuiWidgets.DividerContainer Panes => field ??= BuildPanes();

DrawStatusBar();
/// <summary>
/// Builds the resizable pane layout: the graph, and beside it the properties above the code.
/// </summary>
/// <returns>The container to tick each frame.</returns>
/// <remarks>
/// Properties and code are stacked rather than placed side by side because they are read at
/// different times and want different shapes: properties are a short column of labelled rows,
/// while generated source is lines that want to be read down. Sharing one column gives each of
/// them the full width and lets the user decide how the height is split between them — which is
/// the point of making these panes rather than fixed regions.
/// <para>
/// The sizes are remembered between runs, so an arrangement the user settled on is the one they
/// come back to.
/// </para>
/// </remarks>
private ImGuiWidgets.DividerContainer BuildPanes()
{
ImGuiWidgets.DividerContainer side = new(
"coder-side",
container => Settings.PropertiesSplit = container.GetSizes()[0],
ImGuiWidgets.DividerLayout.Rows,
[
new ImGuiWidgets.DividerZone("properties", Settings.PropertiesSplit, DrawPropertiesPane),
new ImGuiWidgets.DividerZone("code", 1f - Settings.PropertiesSplit, _ => DrawCodePane()),
]);

return new ImGuiWidgets.DividerContainer(
"coder-panes",
container => Settings.GraphSplit = container.GetSizes()[0],
ImGuiWidgets.DividerLayout.Columns,
[
new ImGuiWidgets.DividerZone("graph", Settings.GraphSplit, deltaTime =>
Editor.Draw(ImGui.GetContentRegionAvail(), deltaTime)),
new ImGuiWidgets.DividerZone("side", 1f - Settings.GraphSplit, side.Tick),
]);
}

/// <summary>
/// Draws the selected node's properties, which the editor supplies but does not place.
/// </summary>
/// <param name="deltaTime">Seconds since the last frame; the panel does not animate, so unused.</param>
private void DrawPropertiesPane(float deltaTime)
{
ImGui.TextUnformatted("Properties");
Editor.DrawInspector(ImGui.GetContentRegionAvail());
}

/// <summary>
Expand Down Expand Up @@ -185,16 +280,11 @@
/// </summary>
private const float StatusBarHeight = 28f;

/// <summary>
/// The margin between a pane's edge and what it contains.
/// </summary>
private const float PaneInset = 12f;

/// <summary>
/// Draws the application's File menu.
/// </summary>
/// <remarks>Called from inside the application's main menu bar, so it opens no bar of its own.</remarks>
public void DrawMenu()

Check warning on line 287 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.

Check warning on line 287 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 24 to the 15 allowed.
{
if (ImGui.BeginMenu("File"))
{
Expand Down Expand Up @@ -231,7 +321,7 @@

if (Settings.RecentFiles.Count > 0 && ImGui.BeginMenu("Recent"))
{
foreach (string recent in Settings.RecentFiles.ToArray())

Check warning on line 324 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method

Check warning on line 324 in Coder.Editor/CoderEditorApp.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Loops should be simplified using the "Where" LINQ method
{
if (ImGui.MenuItem(recent))
{
Expand Down Expand Up @@ -433,7 +523,8 @@
/// </remarks>
private void Replace(AstNode document, string status)
{
Editor = new AstGraphEditor(document) { LayoutRunning = Settings.LayoutRunning };
Editor = EditorFor(document);
Editor.LayoutRunning = Settings.LayoutRunning;
DocumentPath = null;
Status = status;
GeneratedCode = string.Empty;
Expand All @@ -453,7 +544,8 @@
return false;
}

Editor = new AstGraphEditor(result.Root) { LayoutRunning = Settings.LayoutRunning };
Editor = EditorFor(result.Root);
Editor.LayoutRunning = Settings.LayoutRunning;
DocumentPath = result.Path;
Settings.Remember(result.Path!);
Status = $"Opened {result.Path}.";
Expand Down
46 changes: 46 additions & 0 deletions Coder.Editor/EditorSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,52 @@ public sealed class EditorSettings
/// </summary>
public bool LayoutRunning { get; set; } = true;

/// <summary>
/// Gets or sets the window's width when it is not maximized.
/// </summary>
public float WindowWidth { get; set; } = 1280f;

/// <summary>
/// Gets or sets the window's height when it is not maximized.
/// </summary>
public float WindowHeight { get; set; } = 720f;

/// <summary>
/// Gets or sets the window's horizontal position when it is not maximized.
/// </summary>
/// <remarks>
/// Defaulted to the windowing layer's own "no position yet" value, which is deliberately far off
/// screen and means the platform should place the window. A first run therefore opens wherever
/// the system would have put it rather than in a corner this application chose.
/// </remarks>
public float WindowX { get; set; } = -short.MinValue;

/// <summary>
/// Gets or sets the window's vertical position when it is not maximized.
/// </summary>
public float WindowY { get; set; } = -short.MinValue;

/// <summary>
/// Gets or sets a value indicating whether the window was maximized.
/// </summary>
/// <remarks>
/// Kept alongside the size rather than instead of it: a maximized window still has a size to go
/// back to when it is restored, and that is the one worth remembering.
/// </remarks>
public bool WindowMaximized { get; set; }

/// <summary>
/// Gets or sets the share of the window's width the graph takes, the rest going to the panel
/// beside it.
/// </summary>
public float GraphSplit { get; set; } = 0.62f;

/// <summary>
/// Gets or sets the share of that panel's height the properties take, the rest going to the code
/// preview under them.
/// </summary>
public float PropertiesSplit { get; set; } = 0.4f;

/// <summary>
/// Records a file as the most recently opened, without letting the list grow or repeat.
/// </summary>
Expand Down
92 changes: 3 additions & 89 deletions Coder.Graph/AstGraph.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,25 +43,6 @@ public sealed class AstGraph
/// </summary>
private const float SiblingSpacing = 110f;

/// <summary>
/// The clear space left between the boxes of two nodes.
/// </summary>
private const float NodeMargin = 24f;

/// <summary>
/// The furthest a pair of overlapping nodes is moved apart in one step, in pixels.
/// </summary>
/// <remarks>
/// The overlap is otherwise resolved in full each step, because a partial correction loses:
/// between two linked nodes the spring pulls them back together by more, every frame, than a
/// fraction of the overlap pushes them apart, and they come to rest still overlapping. Resolving
/// it in full and capping the step keeps the correction decisive and still lets a deep overlap —
/// two nodes dropped on the same spot — slide apart over several frames rather than jumping.
/// </remarks>
private const float MaxSeparationStep = 40f;

private const float SeparationTolerance = 0.5f;

private readonly Dictionary<AstNode, Vector2> positions = new(ReferenceEqualityComparer.Instance);
private readonly Dictionary<int, AstNode> nodesById = [];
private readonly Dictionary<AstNode, int> idsByNode = new(ReferenceEqualityComparer.Instance);
Expand Down Expand Up @@ -215,10 +196,9 @@ public int AddDetached(AstNode node, Vector2 position)
/// <param name="position">Where the node was asked to go.</param>
/// <returns>That position, or the nearest free one along a diagonal from it.</returns>
/// <remarks>
/// Two nodes at exactly the same point stay there for ever: the layout's repulsion is computed
/// from the direction between them, and coincident points have no direction. Creating two nodes
/// from the palette without moving the mouse is enough to hit that, so the offset is applied when
/// the node is placed rather than left for the simulation to sort out.
/// Creating two nodes from the palette without moving the mouse puts them at the same point. The
/// simulation does pull them apart from there, but a node that appears exactly on top of the last
/// one and then slides out is a worse answer than one that is placed clear to begin with.
/// </remarks>
private Vector2 Separated(Vector2 position)
{
Expand All @@ -233,72 +213,6 @@ private Vector2 Separated(Vector2 position)
return candidate;
}

/// <summary>
/// Eases apart any nodes whose boxes are on top of one another, by one step's worth.
/// </summary>
/// <returns>The deepest overlap found, in pixels, or zero when nothing overlapped.</returns>
/// <remarks>
/// The force-directed layout treats every node as a point: repulsion is computed between centres
/// and the link spring pulls to a fixed length, neither of which knows how wide a node is. Two
/// nodes can therefore sit at a perfectly comfortable distance by that measure and still have
/// their boxes squarely on top of each other, which is what a user sees. This resolves the
/// overlap the layout cannot see, working on the drawn rectangles rather than on centres.
/// <para>
/// Each overlap is resolved along the axis it is shallowest on, which is both the shorter push
/// and the one that leaves the arrangement the layout worked out most nearly as it was, and by no
/// more than <see cref="MaxSeparationStep"/> at a time, so a deep overlap slides apart over a few
/// frames rather than jumping.
/// </para>
/// </remarks>
public float SeparateOverlaps()
{
Node[] nodes = [.. Engine.Nodes];
Vector2[] moved = [.. nodes.Select(node => node.Position)];
float deepest = 0f;

for (int i = 0; i < nodes.Length; i++)
{
for (int j = i + 1; j < nodes.Length; j++)
{
Vector2 clearance = ((nodes[i].Dimensions + nodes[j].Dimensions) * 0.5f) + new Vector2(NodeMargin);
Vector2 first = moved[i] + (nodes[i].Dimensions * 0.5f);
Vector2 second = moved[j] + (nodes[j].Dimensions * 0.5f);
Vector2 between = second - first;
Vector2 overlap = clearance - Vector2.Abs(between);

if (overlap.X <= SeparationTolerance || overlap.Y <= SeparationTolerance)
{
continue;
}

// Never further than the overlap itself, so the pair cannot be driven past each other and
// back again, and shared equally between them so the arrangement's centre stays put.
float depth = Math.Min(overlap.X, overlap.Y);
float amount = Math.Min(depth, MaxSeparationStep) * 0.5f;

// A zero component has no side to be on, so the later node is pushed the positive way:
// an arbitrary choice, but a consistent one, which is what stops the pair jittering.
Vector2 push = overlap.X < overlap.Y
? new Vector2(amount * (between.X < 0f ? -1f : 1f), 0f)
: new Vector2(0f, amount * (between.Y < 0f ? -1f : 1f));

moved[j] += push;
moved[i] -= push;
deepest = Math.Max(deepest, depth);
}
}

for (int i = 0; i < nodes.Length; i++)
{
if (moved[i] != nodes[i].Position)
{
Engine.UpdateNodePosition(nodes[i].Id, moved[i]);
}
}

return deepest;
}

/// <summary>
/// Reports where a node currently sits.
/// </summary>
Expand Down
Loading