Commit: 8fb4d8b
Parent: e21669c

Fit button

Mårten Åsberg committed on 2026-10-29 at 20:15
Assisted-by: GitHub Copilot:gpt-6.1-sol
README.md +4 -0
diff --git a/README.md b/README.md
index 2ec04f7..af02fa2 100644
@@ -1,3 +1,7 @@
# Code Flow Analysis
Special built for the event driven system I have at work.
Analyses code paths from a give file:line and can render a call graph.
![](./ui/tests/Snapshots/analyzer-fit.png)
ui/src/Views/MainWindow.axaml +1 -0
diff --git a/ui/src/Views/MainWindow.axaml b/ui/src/Views/MainWindow.axaml
index 97eb792..12c0945 100644
@@ -45,6 +45,7 @@
/>
<Button
Grid.Column="2"
Name="FitGraphButton"
Margin="8,0,0,0"
Content="Fit graph"
Click="FitGraph"
ui/src/Views/MainWindow.axaml.cs +18 -3
diff --git a/ui/src/Views/MainWindow.axaml.cs b/ui/src/Views/MainWindow.axaml.cs
index c91b84d..95dd39a 100644
@@ -1,5 +1,7 @@
using System;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Markup.Xaml;
@@ -53,9 +55,22 @@ public sealed partial class MainWindow : Window
editor.UpdateLayout();
if (model?.Scene is { } scene)
{
var horizontal = Math.Max(Math.Abs(scene.Bounds.Left), Math.Abs(scene.Bounds.Right));
var vertical = Math.Max(Math.Abs(scene.Bounds.Top), Math.Abs(scene.Bounds.Bottom));
editor.FitToScreen(new Avalonia.Rect(-horizontal, -vertical, horizontal * 2, vertical * 2));
if (scene.Nodes.Count > 0 && editor.Bounds.Width > 0 && editor.Bounds.Height > 0)
{
var bounds = scene.Nodes.Select(node => node.Bounds).Aggregate((first, next) => first.Union(next));
var zoom = Math.Min(
editor.MaxViewportZoom,
Math.Min(editor.Bounds.Width / bounds.Width, editor.Bounds.Height / bounds.Height)
);
editor.MinViewportZoom = Math.Min(editor.MinViewportZoom, zoom);
editor.ViewportZoom = zoom;
editor.ViewportLocation =
bounds.Center
- new Point(
editor.Bounds.Width / editor.ViewportZoom / 2,
editor.Bounds.Height / editor.ViewportZoom / 2
);
}
}
}
}
ui/tests/MainWindowTests/InteractShould.cs +4 -6
diff --git a/ui/tests/MainWindowTests/InteractShould.cs b/ui/tests/MainWindowTests/InteractShould.cs
index 20d0903..09bdac7 100644
@@ -256,12 +256,10 @@ public sealed class InteractShould
);
buttons.ShouldAllBe(button => button.Bounds.Width > 0 && button.Bounds.Height > 0);
var editor = window.FindControl<NodifyEditor>("Editor")!;
var origin = buttons.Single(button => ((GraphNode)button.DataContext!).IsOrigin);
var originCenter = origin
.TranslatePoint(new Point(origin.Bounds.Width / 2, origin.Bounds.Height / 2), editor)!
.Value;
originCenter.X.ShouldBe(editor.Bounds.Width / 2, 2);
originCenter.Y.ShouldBe(editor.Bounds.Height / 2, 2);
var bounds = scene.Nodes.Select(node => node.Bounds).Aggregate((first, next) => first.Union(next));
var center = (bounds.Center - editor.ViewportLocation) * editor.ViewportZoom;
center.X.ShouldBe(editor.Bounds.Width / 2, 2);
center.Y.ShouldBe(editor.Bounds.Height / 2, 2);
window.GetVisualDescendants().OfType<GraphConnection>().Count().ShouldBe(scene.Edges.Count);
frame.PixelSize.Width.ShouldBeGreaterThan(800);
var stride = frame.PixelSize.Width * 4;
ui/tests/MainWindowTests/RenderShould.cs +138 -0
diff --git a/ui/tests/MainWindowTests/RenderShould.cs b/ui/tests/MainWindowTests/RenderShould.cs
new file mode 100644
index 0000000..2969643
@@ -0,0 +1,138 @@
using System;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Headless.XUnit;
using Avalonia.Input;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using Cfa.Ui.Services;
using Cfa.Ui.ViewModels;
using Cfa.Ui.Views;
using Nodify;
using Shouldly;
namespace Cfa.Ui.Tests.MainWindowTests;
public sealed class RenderShould
{
[AvaloniaFact]
public async Task MatchThePreviousBitmapAfterFittingTheRealSolutionGraph()
{
// Arrange
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "code-flow-analysis.slnx")))
directory = directory.Parent;
var root = directory.ShouldNotBeNull().FullName;
using var model = new MainViewModel(new Desktop(root), new ScanSessionFactory());
var window = new MainWindow
{
DataContext = model,
Width = 1400,
Height = 900,
};
window.Show();
try
{
await model.OpenSolutionCommand.ExecuteAsync(null);
model.Address = "core/tests/AnalyzerTests/AnalyzeShould.cs:15";
await model.AnalyzeCommand.ExecuteAsync(null);
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
var editor = window.FindControl<NodifyEditor>("Editor")!;
editor.ViewportZoom = 0.2;
editor.ViewportLocation = new Point(-500, -500);
var fit = window.FindControl<Button>("FitGraphButton")!;
var point = fit.TranslatePoint(new Point(fit.Bounds.Width / 2, fit.Bounds.Height / 2), window)!.Value;
// Act
window.MouseDown(point, MouseButton.Left);
window.MouseUp(point, MouseButton.Left);
window.MouseMove(new Point(5, 5));
Dispatcher.UIThread.RunJobs();
window.UpdateLayout();
// Repaint the entire window rather than reusing incremental frame damage.
using var actual = new RenderTargetBitmap(new PixelSize(1400, 900), new Vector(96, 96));
actual.Render(window);
// Assert
var scene = model.Scene.ShouldNotBeNull();
var bounds = scene.Nodes.Select(node => node.Bounds).Aggregate((first, next) => first.Union(next));
var center = (bounds.Center - editor.ViewportLocation) * editor.ViewportZoom;
center.X.ShouldBe(editor.Bounds.Width / 2, 0.01);
center.Y.ShouldBe(editor.Bounds.Height / 2, 0.01);
var available = new Rect(editor.Bounds.Size);
editor.ViewportZoom.ShouldBe(
Math.Min(
editor.MaxViewportZoom,
Math.Min(editor.Bounds.Width / bounds.Width, editor.Bounds.Height / bounds.Height)
),
0.000001
);
foreach (var node in scene.Nodes)
{
var location = (node.Location - editor.ViewportLocation) * editor.ViewportZoom;
available
.Inflate(0.000001)
.Contains(new Rect(location, new Size(node.Width, node.Height) * editor.ViewportZoom))
.ShouldBeTrue();
}
if (Environment.GetEnvironmentVariable("CFA_UI_ARTIFACTS") is { } artifacts)
{
Directory.CreateDirectory(artifacts);
actual.Save(Path.Combine(artifacts, "analyzer-fit.png"));
}
if (Environment.GetEnvironmentVariable("CFA_UPDATE_SNAPSHOTS") == "1")
actual.Save(Path.Combine(root, "ui/tests/Snapshots/analyzer-fit.png"));
using var expected = new Bitmap(Path.Combine(root, "ui/tests/Snapshots/analyzer-fit.png"));
actual.PixelSize.ShouldBe(expected.PixelSize);
// Decode both images identically to normalize their native pixel formats.
using var encoded = new MemoryStream();
actual.Save(encoded);
encoded.Position = 0;
using var normalized = new Bitmap(encoded);
var actualPixels = Pixels(normalized);
var expectedPixels = Pixels(expected);
actualPixels
.SequenceEqual(expectedPixels)
.ShouldBeTrue("The rendered bitmap must match every baseline pixel.");
}
finally
{
window.Close();
}
}
private static byte[] Pixels(Bitmap bitmap)
{
var stride = bitmap.PixelSize.Width * 4;
var pixels = new byte[stride * bitmap.PixelSize.Height];
var buffer = Marshal.AllocHGlobal(pixels.Length);
try
{
bitmap.CopyPixels(new PixelRect(bitmap.PixelSize), buffer, pixels.Length, stride);
Marshal.Copy(buffer, pixels, 0, pixels.Length);
return pixels;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
private sealed class Desktop(string root) : IDesktopServices
{
public Task<FileInfo?> PickSolutionAsync() =>
Task.FromResult<FileInfo?>(new FileInfo(Path.Combine(root, "code-flow-analysis.slnx")));
public Task ShowErrorAsync(string message) => throw new InvalidOperationException(message);
public Task OpenSourceAsync(string link) => Task.CompletedTask;
}
}
ui/tests/Snapshots/analyzer-fit.png +0 -0
diff --git a/ui/tests/Snapshots/analyzer-fit.png b/ui/tests/Snapshots/analyzer-fit.png
new file mode 100644
index 0000000..3a732ea
Binary files /dev/null and b/ui/tests/Snapshots/analyzer-fit.png differ