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
@@ -0,0 +1,339 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Text;
using AvParser.Core.Parsing;
using AvParser.Core.Settings;
using Microsoft.Extensions.Logging;
using ReactiveUI;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.SourceGenerators;
namespace AvParser.UI.ViewModels;
/// <summary>Runs a parser over pasted text and streams the results into the UI.</summary>
/// <remarks>
/// This page exists to exercise the whole <see cref="IParser{TInput,TOutput}"/> contract —
/// streaming, progress and cancellation — rather than to be a finished feature.
/// </remarks>
public partial class ParseViewModel : PageViewModel
{
/// <summary>Records buffered before being pushed to the UI collection in one go.</summary>
private const int BatchSize = 512;
/// <summary>
/// Upper bound on rows shown. Beyond this the parse still completes and the count stays
/// accurate, but the list stops growing — truncation is reported, never silent.
/// </summary>
private const int MaxDisplayedRecords = 20_000;
private readonly IParserCatalog _catalog;
private readonly ISettingsService _settings;
private readonly ILogger<ParseViewModel> _logger;
private readonly ISequencer _mainThread;
private readonly ObservableAsPropertyHelper<bool> _isBusy;
private CancellationTokenSource? _cancellation;
/// <summary>Text to parse.</summary>
[Reactive]
public partial string InputText { get; set; }
/// <summary>Parser applied by <see cref="ParseCommand"/>.</summary>
[Reactive]
public partial ITextParser SelectedParser { get; set; }
/// <summary>Completion of the running parse, 0.0 to 1.0.</summary>
[Reactive]
public partial double Progress { get; set; }
/// <summary>Outcome summary shown under the toolbar; <see langword="null"/> when idle.</summary>
[Reactive]
public partial string? StatusMessage { get; set; }
/// <summary>Creates the page.</summary>
/// <param name="catalog">Available parsers.</param>
/// <param name="settings">Used to remember the selected parser.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">
/// Scheduler used to marshal collection and progress updates back to the UI thread. Tests
/// pass <see cref="ImmediateSequencer.Instance"/> to make everything synchronous.
/// </param>
public ParseViewModel(
IParserCatalog catalog,
ISettingsService settings,
ILogger<ParseViewModel> logger,
ISequencer? mainThread = null
)
{
_catalog = catalog ?? throw new ArgumentNullException(nameof(catalog));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
InputText = string.Empty;
SelectedParser = catalog.FindOrDefault(settings.Current.LastParserId);
var canParse = this.WhenAnyValue(x => x.InputText)
.Select(static text => !string.IsNullOrWhiteSpace(text))
.DistinctUntilChanged();
ParseCommand = ReactiveCommand.CreateFromTask(RunParseAsync, canParse, _mainThread);
_isBusy = ParseCommand.IsExecuting.ToProperty(this, nameof(IsBusy), false, _mainThread);
CancelCommand = ReactiveCommand.Create(() => _cancellation?.Cancel(), ParseCommand.IsExecuting, _mainThread);
ClearCommand = ReactiveCommand.Create(
() =>
{
InputText = string.Empty;
ClearResults();
StatusMessage = null;
Progress = 0d;
},
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
LoadSampleCommand = ReactiveCommand.Create(
() => InputText = SampleFor(SelectedParser.Id),
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
GenerateLargeSampleCommand = ReactiveCommand.Create(
() => InputText = LargeSampleFor(SelectedParser.Id),
ParseCommand.IsExecuting.Select(static running => !running),
_mainThread
);
// Remember the parser choice; the debounced settings service coalesces the writes.
this.WhenAnyValue(x => x.SelectedParser)
.Where(static parser => parser is not null)
.Subscribe(parser => _settings.Update(current => current with { LastParserId = parser.Id }));
// Errors surfacing from any command must not tear the process down.
ParseCommand.ThrownExceptions.Subscribe(OnCommandFailed);
}
/// <inheritdoc />
public override string Title => "Parse";
/// <inheritdoc />
public override string IconKey => "IconDocument";
/// <summary>Every registered parser, for the picker.</summary>
public IReadOnlyList<ITextParser> Parsers => _catalog.Parsers;
/// <summary>Successfully parsed records, capped at <see cref="MaxDisplayedRecords"/>.</summary>
public ObservableCollection<ParsedRecord> Records { get; } = [];
/// <summary>Per-line failures. A failure never aborts the parse.</summary>
public ObservableCollection<ParseError> Errors { get; } = [];
/// <summary>Whether a parse is currently running.</summary>
public bool IsBusy => _isBusy.Value;
/// <summary>Runs <see cref="SelectedParser"/> over <see cref="InputText"/>.</summary>
public ReactiveCommand<RxVoid, RxVoid> ParseCommand { get; }
/// <summary>Cancels the running parse.</summary>
public ReactiveCommand<RxVoid, RxVoid> CancelCommand { get; }
/// <summary>Clears the input and all results.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCommand { get; }
/// <summary>Fills the input with a small example for the selected parser.</summary>
public ReactiveCommand<RxVoid, string> LoadSampleCommand { get; }
/// <summary>Fills the input with 50 000 rows, so progress and cancellation are observable.</summary>
public ReactiveCommand<RxVoid, string> GenerateLargeSampleCommand { get; }
private async Task RunParseAsync(CancellationToken commandToken)
{
using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(commandToken);
_cancellation = cancellation;
var parser = SelectedParser;
var input = InputText;
var token = cancellation.Token;
ClearResults();
Progress = 0d;
StatusMessage = null;
var recordBuffer = new List<ParsedRecord>(BatchSize);
var errorBuffer = new List<ParseError>(16);
var progress = new Progress<ParseProgress>(value => OnUi(() => Progress = value.Fraction));
var stopwatch = Stopwatch.StartNew();
var succeeded = 0;
var failed = 0;
var truncated = false;
var cancelled = false;
try
{
await foreach (var outcome in parser.ParseAsync(input, progress, token).ConfigureAwait(false))
{
if (outcome.IsSuccess)
{
succeeded++;
if (succeeded <= MaxDisplayedRecords)
{
recordBuffer.Add(outcome.Value!);
}
else
{
truncated = true;
}
}
else
{
failed++;
errorBuffer.Add(outcome.Error);
}
if (recordBuffer.Count >= BatchSize)
{
FlushBuffers(recordBuffer, errorBuffer);
}
}
}
catch (OperationCanceledException)
{
cancelled = true;
}
finally
{
_cancellation = null;
FlushBuffers(recordBuffer, errorBuffer);
stopwatch.Stop();
}
var summary = BuildSummary(succeeded, failed, stopwatch.Elapsed, truncated, cancelled);
OnUi(() =>
{
StatusMessage = summary;
Progress = cancelled ? Progress : 1d;
});
_logger.LogInformation(
"Parsed with {Parser}: {Succeeded} record(s), {Failed} error(s) in {Elapsed}",
parser.Id,
succeeded,
failed,
stopwatch.Elapsed
);
}
private static string BuildSummary(int succeeded, int failed, TimeSpan elapsed, bool truncated, bool cancelled)
{
var text = new StringBuilder();
text.Append(cancelled ? "Cancelled after " : "Parsed ");
text.Append(succeeded.ToString("N0", CultureInfo.CurrentCulture));
text.Append(succeeded == 1 ? " record" : " records");
if (failed > 0)
{
text.Append(", ").Append(failed.ToString("N0", CultureInfo.CurrentCulture));
text.Append(failed == 1 ? " error" : " errors");
}
text.Append(" in ").Append(elapsed.TotalMilliseconds.ToString("N0", CultureInfo.CurrentCulture)).Append(" ms");
if (truncated)
{
text.Append(" — showing the first ")
.Append(MaxDisplayedRecords.ToString("N0", CultureInfo.CurrentCulture))
.Append(" only");
}
return text.Append('.').ToString();
}
private void FlushBuffers(List<ParsedRecord> records, List<ParseError> errors)
{
if (records.Count == 0 && errors.Count == 0)
{
return;
}
// Copy before clearing: the scheduled callback may run after the loop has refilled these.
var recordBatch = records.ToArray();
var errorBatch = errors.ToArray();
records.Clear();
errors.Clear();
OnUi(() =>
{
foreach (var record in recordBatch)
{
Records.Add(record);
}
foreach (var error in errorBatch)
{
Errors.Add(error);
}
});
}
private void ClearResults()
{
Records.Clear();
Errors.Clear();
}
private void OnCommandFailed(Exception exception)
{
_logger.LogError(exception, "Parse failed");
OnUi(() => StatusMessage = $"Parse failed: {exception.Message}");
}
/// <summary>Marshals a mutation onto the UI thread; the parse loop runs on the thread pool.</summary>
private void OnUi(Action action) => _mainThread.Schedule(action);
private static string SampleFor(string parserId) =>
parserId switch
{
"key-value" => """
# Sample configuration
host = localhost
port: 8080
enabled = true
name = av-parser
""",
_ => """
id,name,role
1,Ada Lovelace,Analyst
2,Grace Hopper,Compiler
3,Alan Turing,Cryptanalyst
""",
};
private static string LargeSampleFor(string parserId)
{
const int rows = 50_000;
var text = new StringBuilder(rows * 24);
if (parserId == "key-value")
{
for (var i = 0; i < rows; i++)
{
text.Append("key").Append(i).Append(" = value").Append(i).Append('\n');
}
return text.ToString();
}
text.Append("id,name,score\n");
for (var i = 0; i < rows; i++)
{
text.Append(i).Append(",item-").Append(i).Append(',').Append(i % 100).Append('\n');
}
return text.ToString();
}
}