Scaffold AvParser: Avalonia 12 shell with adaptive layout

Greenfield skeleton for a parser desktop app. The domain is deliberately a
placeholder — IParser<TIn,TOut> plus two sample parsers — so the shell is
runnable and verifiable end to end before real logic lands.

Layers run one way: Core (no Avalonia, no IO) <- Infrastructure <- UI <- Desktop.
UI is a class library rather than the exe so headless tests build real views
without dragging in Program.cs, Serilog or the container.

Adaptive layout is built from what Avalonia actually offers, since it has no
AdaptiveTrigger or media queries: ResponsiveLayout observes Visual.Bounds and
projects a breakpoint onto both an attached property and :compact/:medium/
:expanded pseudoclasses, with 24px hysteresis so dragging a window edge cannot
make the layout flap. Pane state lives in the view model because a style setter
loses to a local value permanently; styles own only the visual variance.

Stack notes worth remembering: Avalonia.ReactiveUI is deprecated in favour of
ReactiveUI.Avalonia, and ReactiveUI 24 runs on the Primitives engine (RxVoid,
ISequencer, Signal<T>) and no longer self-initialises. Avalonia.Headless.XUnit
12.x requires xUnit v3. InvariantGlobalization must stay false or Semi.Avalonia
throws in its static constructor.

102 tests across three projects, including headless guards for the two failures
that are otherwise completely silent: a stylesheet whose selectors match nothing,
and a light palette too low-contrast for cards to read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 16:07:08 +03:00
co-authored by Claude Opus 5
commit 3db9d4dfc6
94 changed files with 5966 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<Application
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:semi="https://irihi.tech/semi"
x:Class="AvParser.Desktop.App"
RequestedThemeVariant="Default"
>
<Application.Styles>
<semi:SemiTheme />
<StyleInclude Source="avares://AvParser.UI/Styles/Index.axaml" />
</Application.Styles>
</Application>
+85
View File
@@ -0,0 +1,85 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using AvParser.Core.Settings;
using AvParser.UI;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.DependencyInjection;
namespace AvParser.Desktop;
/// <summary>The Avalonia application. Owns nothing but wiring.</summary>
/// <remarks>
/// The container arrives through the constructor rather than a static field, so the XAML
/// previewer and any test host can construct an <see cref="App"/> that has no container at all
/// and simply skips the composition step.
/// </remarks>
public partial class App : Application
{
private readonly IServiceProvider? _services;
/// <summary>Parameterless constructor used by the XAML previewer.</summary>
public App()
: this(null) { }
/// <summary>Creates the application over a built container.</summary>
/// <param name="services">The container, or <see langword="null"/> for design/preview mode.</param>
public App(IServiceProvider? services) => _services = services;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode || _services is null)
{
base.OnFrameworkInitializationCompleted();
return;
}
DataTemplates.Add(_services.GetRequiredService<ViewLocator>());
// Resolving the theme service applies the persisted variant as a side effect of construction.
_ = _services.GetRequiredService<IThemeService>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var settings = _services.GetRequiredService<ISettingsService>();
var window = CreateMainWindow(settings);
desktop.MainWindow = window;
desktop.ShutdownRequested += (_, _) => settings.FlushAsync().GetAwaiter().GetResult();
}
base.OnFrameworkInitializationCompleted();
}
private MainWindow CreateMainWindow(ISettingsService settings)
{
var window = new MainWindow
{
DataContext = _services!.GetRequiredService<ShellViewModel>(),
Width = settings.Current.WindowWidth,
Height = settings.Current.WindowHeight,
WindowState = settings.Current.WindowMaximized ? WindowState.Maximized : WindowState.Normal,
};
window.Closing += (_, _) =>
settings.Update(current =>
current with
{
WindowMaximized = window.WindowState == WindowState.Maximized,
// Persist the restored size, not the maximised one, or un-maximising
// on the next run would leave the window filling the screen.
WindowWidth = window.WindowState == WindowState.Normal ? window.Width : current.WindowWidth,
WindowHeight = window.WindowState == WindowState.Normal ? window.Height : current.WindowHeight,
}
);
return window;
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>AvParser.Desktop</RootNamespace>
<AssemblyName>AvParser</AssemblyName>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- The previewer and `dotnet run` both want a console-free window on Windows; on Linux and
macOS WinExe is equivalent to Exe. -->
<ApplicationIcon></ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
<ProjectReference Include="..\AvParser.UI\AvParser.UI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="Semi.Avalonia" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<!-- F12 opens DevTools, which is the fastest way to see :compact/:medium/:expanded toggle. -->
<PackageReference Include="Avalonia.Diagnostics" />
</ItemGroup>
</Project>
@@ -0,0 +1,22 @@
using Serilog;
namespace AvParser.Desktop.Logging;
/// <summary>
/// Catches exceptions ReactiveUI would otherwise rethrow on the scheduler and kill the process with.
/// </summary>
/// <remarks>
/// Must be installed while ReactiveUI is being configured, i.e. before the first
/// <c>ReactiveCommand</c> is constructed. Installing it later leaves already-built commands on
/// the default handler.
/// </remarks>
internal sealed class SerilogExceptionHandler(ILogger logger) : IObserver<Exception>
{
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public void OnNext(Exception value) => _logger.Error(value, "Unhandled ReactiveUI exception");
public void OnError(Exception error) => _logger.Fatal(error, "ReactiveUI exception stream failed");
public void OnCompleted() { }
}
+81
View File
@@ -0,0 +1,81 @@
using Avalonia;
using Avalonia.Controls;
using AvParser.Core.DependencyInjection;
using AvParser.Core.Settings;
using AvParser.Desktop.Logging;
using AvParser.Infrastructure.DependencyInjection;
using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage;
using AvParser.UI.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI.Avalonia;
using Serilog;
namespace AvParser.Desktop;
/// <summary>Composition root and process entry point.</summary>
internal static class Program
{
/// <summary>Builds the container, starts Avalonia, and flushes logs on the way out.</summary>
[STAThread]
public static int Main(string[] args)
{
var paths = new AppPaths();
paths.EnsureCreated();
// The persisted level cannot be read before the container exists, and the container needs
// a logger. Start at Information and narrow it once settings are available.
var (logger, levelSwitch) = AppLogging.Create(paths, "Information");
Log.Logger = logger;
try
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddSerilog(logger, dispose: false));
services.AddSingleton(levelSwitch);
services.AddAvParserCore();
services.AddAvParserInfrastructure(paths);
services.AddAvParserUI();
using var provider = services.BuildServiceProvider(
new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }
);
var settings = provider.GetRequiredService<ISettingsService>();
levelSwitch.MinimumLevel = AppLogging.ParseLevel(settings.Current.MinimumLogLevel);
Log.Information("AvParser starting; data directory {DataDirectory}", paths.DataDirectory);
return BuildAvaloniaApp(provider).StartWithClassicDesktopLifetime(args, ShutdownMode.OnMainWindowClose);
}
catch (Exception ex)
{
Log.Fatal(ex, "AvParser terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
/// <summary>
/// Entry point the XAML previewer reflects for.
/// </summary>
/// <remarks>
/// It must stay parameterless and unambiguous: an optional parameter makes the previewer's
/// zero-argument invoke throw, and a second overload of the same name makes its
/// <c>GetMethod("BuildAvaloniaApp")</c> throw <see cref="System.Reflection.AmbiguousMatchException"/>.
/// Hence the distinct name for the real builder below.
/// </remarks>
public static AppBuilder BuildAvaloniaApp() => BuildAvaloniaApp(null);
private static AppBuilder BuildAvaloniaApp(IServiceProvider? services) =>
AppBuilder
.Configure(() => new App(services))
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI(builder => builder.WithExceptionHandler(new SerilogExceptionHandler(Log.Logger)));
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AvParser.Desktop" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Without per-monitor-v2 the shell is bitmap-stretched on a scaled display, which makes
the whole point of a crisp adaptive layout moot. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>