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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,4 @@ cmake-build-*/

controlgallery/AtomUILabsGallery/GeneratedFiles/Avalonia.Generators/
.claude/settings.local.json
.codex
3 changes: 3 additions & 0 deletions AtomUI.Labs.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<Solution>
<Project Path="src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj" />
<Project Path="src/AtomUI.Labs.Controls.ImageGallery/AtomUI.Labs.Controls.ImageGallery.csproj" />
<Project Path="tests/AtomUI.Labs.Controls.Led.Tests/AtomUI.Labs.Controls.Led.Tests.csproj" />
<Project Path="tests/AtomUI.Labs.Controls.ImageGallery.Tests/AtomUI.Labs.Controls.ImageGallery.Tests.csproj" />
<Project Path="tests/AtomUI.Labs.Controls.ImageGallery.Benchmarks/AtomUI.Labs.Controls.ImageGallery.Benchmarks.csproj" />
<Project Path="controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj" />
<Project Path="controlgallery/AtomUILabsGallery.Desktop/AtomUILabsGallery.Desktop.csproj" />
</Solution>
127 changes: 126 additions & 1 deletion controlgallery/AtomUILabsGallery/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
using AtomUI;
using AtomUI.Desktop.Controls;
using AtomUI.Theme;
using AtomUILabsGallery.ShowCases.ImageGallery.Aot;
using AtomUILabsGallery.Workspace.Views;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Text;

namespace AtomUILabsGallery;

Expand Down Expand Up @@ -33,9 +37,130 @@ public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new WorkspaceWindow();
desktop.MainWindow = desktop.Args?.Contains("--image-gallery-smoke", StringComparer.Ordinal) == true
? CreateImageGallerySmokeWindow(desktop)
: new WorkspaceWindow();
}

base.OnFrameworkInitializationCompleted();
}

private static Window CreateImageGallerySmokeWindow(IClassicDesktopStyleApplicationLifetime desktop)
{
var view = new ImageGalleryAotSmokeView();
var window = new Window
{
Width = 800,
Height = 520,
Content = view,
ShowInTaskbar = false,
Opacity = 0.01,
};
window.Opened += async (_, _) =>
{
var startedAt = DateTimeOffset.UtcNow;
var stopwatch = Stopwatch.StartNew();
var exitCode = 0;
Exception? failure = null;
IReadOnlyList<ImageGallerySmokeCheck> checks = [];
var args = desktop.Args ?? [];
var resultPath = GetArgumentValue(args, "--result");
var timeoutText = GetArgumentValue(args, "--timeout-seconds");
if (timeoutText is not null && (!int.TryParse(timeoutText, out var parsed) || parsed is < 1 or > 600))
{
exitCode = 2;
failure = new ArgumentException("--timeout-seconds must be an integer from 1 through 600.");
}
else
{
var timeout = TimeSpan.FromSeconds(timeoutText is null ? 90 : int.Parse(timeoutText));
using var cancellation = new CancellationTokenSource(timeout);
try
{
checks = await view.RunAsync(window, timeout, cancellation.Token);
}
catch (OperationCanceledException)
{
exitCode = 3;
failure = new TimeoutException("ImageGallery NativeAOT smoke timed out.");
}
catch (TimeoutException exception)
{
exitCode = 3;
failure = exception;
}
catch (ImageGallerySmokeAssertionException exception)
{
exitCode = 4;
failure = exception;
}
catch (Exception exception)
{
exitCode = 5;
failure = exception;
}
}

stopwatch.Stop();
if (resultPath is not null)
{
WriteSmokeResult(resultPath, startedAt, stopwatch.Elapsed, checks, failure, exitCode);
}

window.Close();
desktop.Shutdown(exitCode);
};
return window;
}

private static string? GetArgumentValue(IReadOnlyList<string> args, string name)
{
for (var index = 0; index + 1 < args.Count; index++)
{
if (string.Equals(args[index], name, StringComparison.Ordinal))
{
return args[index + 1];
}
}

return null;
}

private static void WriteSmokeResult(
string path,
DateTimeOffset startedAt,
TimeSpan duration,
IReadOnlyList<ImageGallerySmokeCheck> checks,
Exception? failure,
int exitCode)
{
var fullPath = Path.GetFullPath(path);
Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!);
var builder = new StringBuilder();
builder.AppendLine("{");
builder.AppendLine(" \"SchemaVersion\": 1,");
builder.AppendLine($" \"RuntimeIdentifier\": \"{RuntimeInformation.RuntimeIdentifier}\",");
builder.AppendLine($" \"FrameworkDescription\": \"{Escape(RuntimeInformation.FrameworkDescription)}\",");
builder.AppendLine($" \"StartedAt\": \"{startedAt:O}\",");
builder.AppendLine($" \"DurationMilliseconds\": {duration.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture)},");
builder.AppendLine(" \"Checks\": [");
for (var index = 0; index < checks.Count; index++)
{
var check = checks[index];
builder.Append($" {{ \"Name\": \"{Escape(check.Name)}\", \"Status\": \"Passed\", \"DurationMilliseconds\": {check.Duration.TotalMilliseconds.ToString("F3", CultureInfo.InvariantCulture)} }}");
builder.AppendLine(index + 1 == checks.Count ? string.Empty : ",");
}

builder.AppendLine(" ],");
builder.AppendLine($" \"UnhandledException\": {(failure is null ? "null" : $"\"{Escape(failure.ToString())}\"")},");
builder.AppendLine($" \"Result\": \"{(exitCode == 0 ? "Passed" : "Failed")}\"");
builder.AppendLine("}");
File.WriteAllText(fullPath, builder.ToString());
}

private static string Escape(string value) => value
.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("\r", "\\r", StringComparison.Ordinal)
.Replace("\n", "\\n", StringComparison.Ordinal);
}
3 changes: 3 additions & 0 deletions controlgallery/AtomUILabsGallery/AtomUILabsGallery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,12 @@

<ItemGroup>
<AvaloniaResource Include="../../resources/images/atomui-labs.png" Link="Assets/atomui-labs.png"/>
<AvaloniaResource Include="../../resources/images/image-gallery/*.jpg"
Link="Assets/ImageGallery/%(Filename)%(Extension)"/>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="../../src/AtomUI.Labs.Controls.ImageGallery/AtomUI.Labs.Controls.ImageGallery.csproj"/>
<ProjectReference Include="../../src/AtomUI.Labs.Controls.Led/AtomUI.Labs.Controls.Led.csproj"/>
<PackageReference Include="Avalonia"/>
<PackageReference Include="AtomUI.Desktop.Controls"/>
Expand Down
3 changes: 3 additions & 0 deletions controlgallery/AtomUILabsGallery/AtomUILabsGalleryModule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using AtomUI.Toolkits.GalleryBase.Routing;
using AtomUILabsGallery.ShowCases;
using AtomUILabsGallery.ShowCases.Led;
using AtomUILabsGallery.ShowCases.ImageGallery;
using Avalonia.Controls;
using ReactiveUI;

Expand Down Expand Up @@ -52,13 +53,15 @@ private static void ConfigureNavigation(AtomUI.Toolkits.GalleryBase.Navigation.G
navigation.AddPage(OverviewViewModel.ID, "Overview", Icon(AntDesignIconKind.HomeOutlined));

var labs = navigation.AddGroup("Labs", "Labs Controls", Icon(AntDesignIconKind.AppstoreOutlined));
labs.AddPage(ImageGalleryViewModel.ID, "Image Gallery", Icon(AntDesignIconKind.PictureOutlined));
labs.AddPage(LedSegmentViewModel.ID, "LED Segment Display", Icon(AntDesignIconKind.FieldNumberOutlined));
labs.AddPage(LedMatrixViewModel.ID, "LED Matrix Display", Icon(AntDesignIconKind.TableOutlined));
}

private static void ConfigureRoutes(GalleryRouteRegistry routes)
{
routes.Map(OverviewViewModel.ID, screen => new OverviewViewModel(screen), () => new OverviewShowCase());
routes.Map(ImageGalleryViewModel.ID, screen => new ImageGalleryViewModel(screen), () => new ImageGalleryShowCase());
routes.Map(LedSegmentViewModel.ID, screen => new LedSegmentViewModel(screen), () => new LedSegmentShowCase());
routes.Map(LedMatrixViewModel.ID, screen => new LedMatrixViewModel(screen), () => new LedMatrixShowCase());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:atom.labs="https://atomui.net/labs"
xmlns:aot="using:AtomUILabsGallery.ShowCases.ImageGallery.Aot"
x:Class="AtomUILabsGallery.ShowCases.ImageGallery.Aot.ImageGalleryAotSmokeView"
x:DataType="aot:ImageGalleryAotSmokeViewModel">
<atom.labs:ImageGallery x:Name="Gallery"
Width="800"
Height="520"
ItemsSource="{CompiledBinding Items}"
IsAddImageButtonVisible="False" />
</UserControl>
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
using System.Diagnostics;
using AtomUI.Labs.Controls.ImageGallery;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;

namespace AtomUILabsGallery.ShowCases.ImageGallery.Aot;

public partial class ImageGalleryAotSmokeView : UserControl
{
public ImageGalleryAotSmokeView()
{
DataContext = new ImageGalleryAotSmokeViewModel();
InitializeComponent();
}

public async Task<IReadOnlyList<ImageGallerySmokeCheck>> RunAsync(
ContentControl host,
TimeSpan timeout,
CancellationToken cancellationToken)
{
var deadline = DateTime.UtcNow + timeout;
var checks = new List<ImageGallerySmokeCheck>();
await CheckAsync("InitialReady", async () =>
{
await WaitForStateAsync(ImageGalleryImageState.Ready, deadline, cancellationToken);
return Gallery.SelectedIndex == 0;
}, checks);
await CheckAsync("TemplateAndLayout", () =>
{
var required = new[]
{
"PART_Viewport", "PART_Toolbar", "PART_Filmstrip", "PART_ScrollViewer", "PART_ItemsPresenter"
};
var visuals = Gallery.GetVisualDescendants().OfType<Control>().ToArray();
return Task.FromResult(required.All(name =>
visuals.Any(control => control.Name == name && control.Bounds.Width > 0 && control.Bounds.Height > 0)));
}, checks);
await CheckAsync("ResourceOnlyLease", () =>
{
var item = Gallery.SelectedItem as IImageGalleryItem;
Gallery.MainImageDecodeSizeHint = new Avalonia.PixelSize(1024, 768);
Gallery.MainImageMode = ImageGalleryMainImageMode.ResourceOnly;
ImageGalleryImageLease? lease = null;
var acquired = item is not null && Gallery.TryAcquireCurrentImage(item, out lease);
var valid = acquired && lease is not null && lease.DecodedPixelSize.Width > 0;
lease?.Dispose();
Gallery.MainImageMode = ImageGalleryMainImageMode.Presented;
return Task.FromResult(valid && Gallery.ImageState == ImageGalleryImageState.Ready);
}, checks);
await CheckAsync("VirtualizedThousandItems", () =>
{
var realized = Gallery.GetRealizedContainers().Count();
return Task.FromResult(realized is > 0 and < 40);
}, checks);
await CheckAsync("StableNavigationCommands", async () =>
{
Gallery.NextCommand.Execute(null);
await WaitForStateAsync(ImageGalleryImageState.Error, deadline, cancellationToken);
Gallery.NextCommand.Execute(null);
await WaitForStateAsync(ImageGalleryImageState.Ready, deadline, cancellationToken);
Gallery.PreviousCommand.Execute(null);
await WaitForStateAsync(ImageGalleryImageState.Error, deadline, cancellationToken);
return Gallery.SelectedIndex == 1 && Gallery.ImageState == ImageGalleryImageState.Error;
}, checks);
await CheckAsync("ViewportCommands", async () =>
{
Gallery.NextCommand.Execute(null);
await WaitForStateAsync(ImageGalleryImageState.Ready, deadline, cancellationToken);
Gallery.ZoomInCommand.Execute(null);
Gallery.RotateClockwise();
var valid = Gallery.EffectiveZoomFactor > 0 && Gallery.RotationAngle == 90;
Gallery.FitCommand.Execute(null);
Gallery.ActualSizeCommand.Execute(null);
return valid && Gallery.EffectiveZoomFactor > 0;
}, checks);
await CheckAsync("FarSelection", async () =>
{
Gallery.SelectedIndex = 999;
await WaitForStateAsync(ImageGalleryImageState.Ready, deadline, cancellationToken);
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
return Gallery.SelectedIndex == 999 && Gallery.GetRealizedContainers().Count() < 40;
}, checks);
await CheckAsync("DetachAttach", async () =>
{
host.Content = null;
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
host.Content = this;
await WaitForStateAsync(ImageGalleryImageState.Ready, deadline, cancellationToken);
return Gallery.SelectedIndex == 999;
}, checks);
return checks;
}

private async Task WaitForStateAsync(
ImageGalleryImageState state,
DateTime deadline,
CancellationToken cancellationToken)
{
while (Gallery.ImageState != state)
{
cancellationToken.ThrowIfCancellationRequested();
if (DateTime.UtcNow >= deadline)
{
throw new TimeoutException($"ImageGallery did not enter {state}; actual state is {Gallery.ImageState}.");
}

await Task.Delay(20, cancellationToken);
}
}

private static async Task CheckAsync(
string name,
Func<Task<bool>> action,
ICollection<ImageGallerySmokeCheck> checks)
{
var stopwatch = Stopwatch.StartNew();
var passed = await action();
stopwatch.Stop();
checks.Add(new ImageGallerySmokeCheck(name, passed, stopwatch.Elapsed));
if (!passed)
{
throw new ImageGallerySmokeAssertionException(name);
}
}
}

public sealed record ImageGallerySmokeCheck(string Name, bool Passed, TimeSpan Duration);

public sealed class ImageGallerySmokeAssertionException(string checkName)
: Exception($"ImageGallery NativeAOT smoke check '{checkName}' failed.");
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using AtomUI.Labs.Controls.ImageGallery;

namespace AtomUILabsGallery.ShowCases.ImageGallery.Aot;

public sealed class ImageGalleryAotSmokeViewModel
{
public ImageGalleryAotSmokeViewModel()
{
var resource = ImageGallerySources.FromAvaloniaResource(
new Uri("avares://AtomUILabsGallery/Assets/atomui-labs.png"));
Items = Enumerable.Range(0, 1_000)
.Select(index => (IImageGalleryItem)new ImageGalleryItem
{
Key = $"aot-{index}",
Title = $"NativeAOT image {index}",
MainImageSource = index == 1 ? new ExpectedFailureSource(index) : resource,
})
.ToArray();
}

public IReadOnlyList<IImageGalleryItem> Items { get; }

private sealed class ExpectedFailureSource(object identity) : IImageGallerySource
{
public object Identity { get; } = identity;

public ValueTask<ImageGalleryImageLease> LoadAsync(
ImageGalleryImageRequest request,
CancellationToken cancellationToken) =>
ValueTask.FromException<ImageGalleryImageLease>(
new InvalidDataException("Expected NativeAOT smoke failure source."));
}
}
Loading