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:
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.Core</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Deliberately the ONLY dependency: the domain must stay hostable from a CLI,
|
||||
a worker service or a benchmark without dragging in a UI stack. -->
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,27 @@
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Parsing.Samples;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AvParser.Core.DependencyInjection;
|
||||
|
||||
/// <summary>Composition root for the domain layer.</summary>
|
||||
public static class CoreServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers every parser plus the catalog that indexes them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Adding a parser is a one-line change here — that is the whole point of the
|
||||
/// <see cref="ITextParser"/> / <see cref="IParserCatalog"/> split.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddAvParserCore(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<ITextParser, DelimitedTextParser>();
|
||||
services.AddSingleton<ITextParser, KeyValueTextParser>();
|
||||
services.AddSingleton<IParserCatalog, ParserCatalog>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// The pluggable unit of the whole application: turns one input into a stream of outcomes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Results are streamed rather than returned as a batch so that the UI can render partial
|
||||
/// results, report progress and honour cancellation on inputs of arbitrary size.
|
||||
/// </remarks>
|
||||
public interface IParser<in TInput, TOutput>
|
||||
{
|
||||
/// <summary>Stable identifier used for persistence and lookup. Never localise this.</summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>Human-readable name shown in the UI.</summary>
|
||||
string DisplayName { get; }
|
||||
|
||||
/// <summary>One-line explanation of what this parser accepts.</summary>
|
||||
string Description { get; }
|
||||
|
||||
/// <summary>Cheap structural check — must not throw and must not do IO.</summary>
|
||||
bool CanParse(TInput input);
|
||||
|
||||
/// <summary>Streams one outcome per logical record.</summary>
|
||||
IAsyncEnumerable<ParseOutcome<TOutput>> ParseAsync(
|
||||
TInput input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
CancellationToken cancellationToken
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>Read-only view over every registered text parser.</summary>
|
||||
public interface IParserCatalog
|
||||
{
|
||||
/// <summary>All registered parsers, ordered by <see cref="IParser{TInput,TOutput}.DisplayName"/>.</summary>
|
||||
IReadOnlyList<ITextParser> Parsers { get; }
|
||||
|
||||
/// <summary>The parser used when nothing has been chosen yet.</summary>
|
||||
ITextParser DefaultParser { get; }
|
||||
|
||||
/// <summary>Finds a parser by its stable id; <see langword="null"/> when unknown.</summary>
|
||||
ITextParser? Find(string? id);
|
||||
|
||||
/// <summary>Finds a parser by id, falling back to <see cref="DefaultParser"/>.</summary>
|
||||
ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Closed, non-generic facade over <see cref="IParser{TInput, TOutput}"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Open generic interfaces cannot be resolved as <c>IEnumerable<T></c> by the DI container,
|
||||
/// so every text-shaped parser implements this closed interface and gets registered under it.
|
||||
/// </remarks>
|
||||
public interface ITextParser : IParser<string, ParsedRecord>;
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>A recoverable problem with a single record. Parsing continues after one of these.</summary>
|
||||
/// <param name="LineNumber">1-based position of the offending record in the input.</param>
|
||||
/// <param name="Message">What went wrong, phrased for a user rather than a developer.</param>
|
||||
public sealed record ParseError(int LineNumber, string Message)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"Line {LineNumber}: {Message}";
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>
|
||||
/// Result of parsing a single record: either a value or a recoverable <see cref="ParseError"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A struct rather than a class hierarchy: parsers emit one of these per line, and on a
|
||||
/// million-line input the allocation difference is the whole cost of the parse.
|
||||
/// </remarks>
|
||||
public readonly record struct ParseOutcome<T>
|
||||
{
|
||||
private ParseOutcome(T? value, ParseError? error)
|
||||
{
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>The parsed value, or <see langword="null"/> when <see cref="IsSuccess"/> is false.</summary>
|
||||
public T? Value { get; }
|
||||
|
||||
/// <summary>The failure, or <see langword="null"/> when <see cref="IsSuccess"/> is true.</summary>
|
||||
public ParseError? Error { get; }
|
||||
|
||||
/// <summary><see langword="true"/> when a value was produced.</summary>
|
||||
[MemberNotNullWhen(false, nameof(Error))]
|
||||
public bool IsSuccess => Error is null;
|
||||
|
||||
/// <summary>Creates a successful outcome.</summary>
|
||||
public static ParseOutcome<T> Success(T value) => new(value, null);
|
||||
|
||||
/// <summary>Creates a failed outcome.</summary>
|
||||
public static ParseOutcome<T> Failure(ParseError error) =>
|
||||
new(default, error ?? throw new ArgumentNullException(nameof(error)));
|
||||
|
||||
/// <summary>Creates a failed outcome from its parts.</summary>
|
||||
public static ParseOutcome<T> Failure(int lineNumber, string message) =>
|
||||
Failure(new ParseError(lineNumber, message));
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>Progress snapshot reported while a parse is running.</summary>
|
||||
/// <param name="Processed">Records handled so far.</param>
|
||||
/// <param name="Total">Expected total, or <c>0</c> when the size is not known up front.</param>
|
||||
public readonly record struct ParseProgress(int Processed, int Total)
|
||||
{
|
||||
/// <summary>Completion in the range <c>0.0 .. 1.0</c>; <c>0</c> when the total is unknown.</summary>
|
||||
public double Fraction => Total <= 0 ? 0d : Math.Clamp((double)Processed / Total, 0d, 1d);
|
||||
|
||||
/// <summary><see langword="true"/> when the total is unknown and the UI should show a busy indicator.</summary>
|
||||
public bool IsIndeterminate => Total <= 0;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <summary>One named field of a <see cref="ParsedRecord"/>.</summary>
|
||||
/// <param name="Name">Column name, or the positional index rendered as text.</param>
|
||||
/// <param name="Value">Raw field value, already trimmed of surrounding whitespace.</param>
|
||||
public readonly record struct ParsedField(string Name, string Value)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override string ToString() => $"{Name}={Value}";
|
||||
}
|
||||
|
||||
/// <summary>A single successfully parsed record.</summary>
|
||||
/// <param name="LineNumber">1-based position of the record in the source input.</param>
|
||||
/// <param name="Fields">The record's fields, in source order.</param>
|
||||
public sealed record ParsedRecord(int LineNumber, IReadOnlyList<ParsedField> Fields)
|
||||
{
|
||||
/// <summary>Flattened <c>key=value</c> rendering, used by the results list.</summary>
|
||||
public string Summary => string.Join(" ", Fields);
|
||||
|
||||
/// <summary>Looks a field up by name; <see langword="null"/> when absent.</summary>
|
||||
public string? this[string name]
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var field in Fields)
|
||||
{
|
||||
if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return field.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace AvParser.Core.Parsing;
|
||||
|
||||
/// <inheritdoc cref="IParserCatalog" />
|
||||
public sealed class ParserCatalog : IParserCatalog
|
||||
{
|
||||
private readonly Dictionary<string, ITextParser> _byId;
|
||||
|
||||
/// <summary>Builds a catalog from every parser the container resolved.</summary>
|
||||
/// <exception cref="ArgumentException">No parsers were registered, or two share an id.</exception>
|
||||
public ParserCatalog(IEnumerable<ITextParser> parsers)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(parsers);
|
||||
|
||||
Parsers = parsers.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
|
||||
if (Parsers.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one parser must be registered.", nameof(parsers));
|
||||
}
|
||||
|
||||
_byId = new Dictionary<string, ITextParser>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var parser in Parsers)
|
||||
{
|
||||
if (!_byId.TryAdd(parser.Id, parser))
|
||||
{
|
||||
throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ITextParser> Parsers { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITextParser DefaultParser => Parsers[0];
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null;
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Intentionally simple — no quoting, no escapes. It exists to exercise the
|
||||
/// <see cref="IParser{TInput,TOutput}"/> contract end to end, not to replace a CSV library.
|
||||
/// </remarks>
|
||||
public sealed class DelimitedTextParser : ITextParser
|
||||
{
|
||||
private static readonly char[] Candidates = [',', ';', '\t', '|'];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "delimited";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Delimited text";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description =>
|
||||
"First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
|
||||
string input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var lines = TextLines.Split(input);
|
||||
var total = lines.Length;
|
||||
string[]? header = null;
|
||||
char separator = ',';
|
||||
var processed = 0;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = lines[i];
|
||||
var lineNumber = i + 1;
|
||||
processed++;
|
||||
|
||||
if (TextLines.IsSkippable(line))
|
||||
{
|
||||
ReportEvery(progress, processed, total);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
separator = DetectSeparator(line);
|
||||
header = SplitTrimmed(line, separator);
|
||||
ReportEvery(progress, processed, total);
|
||||
continue;
|
||||
}
|
||||
|
||||
var values = SplitTrimmed(line, separator);
|
||||
|
||||
if (values.Length != header.Length)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(
|
||||
lineNumber,
|
||||
$"Expected {header.Length} field(s) but found {values.Length}."
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
var fields = new ParsedField[values.Length];
|
||||
for (var f = 0; f < values.Length; f++)
|
||||
{
|
||||
fields[f] = new ParsedField(header[f], values[f]);
|
||||
}
|
||||
|
||||
yield return ParseOutcome<ParsedRecord>.Success(new ParsedRecord(lineNumber, fields));
|
||||
}
|
||||
|
||||
ReportEvery(progress, processed, total);
|
||||
|
||||
if (processed % TextLines.YieldInterval == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
if (header is null)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(1, "Input contains no header row.");
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(total, total));
|
||||
}
|
||||
|
||||
private static void ReportEvery(IProgress<ParseProgress>? progress, int processed, int total)
|
||||
{
|
||||
if (progress is not null && processed % TextLines.ProgressInterval == 0)
|
||||
{
|
||||
progress.Report(new ParseProgress(processed, total));
|
||||
}
|
||||
}
|
||||
|
||||
private static char DetectSeparator(string headerLine)
|
||||
{
|
||||
var best = Candidates[0];
|
||||
var bestCount = 0;
|
||||
|
||||
foreach (var candidate in Candidates)
|
||||
{
|
||||
var count = headerLine.Count(c => c == candidate);
|
||||
if (count > bestCount)
|
||||
{
|
||||
bestCount = count;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string[] SplitTrimmed(string line, char separator)
|
||||
{
|
||||
var parts = line.Split(separator);
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
parts[i] = parts[i].Trim();
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>
|
||||
/// Sample parser: <c>key=value</c> / <c>key: value</c> lines, ini/env style.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A second sample with a different input shape, so the abstraction is proven against more
|
||||
/// than one implementation before the real domain arrives.
|
||||
/// </remarks>
|
||||
public sealed class KeyValueTextParser : ITextParser
|
||||
{
|
||||
private static readonly char[] Separators = ['=', ':'];
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Id => "key-value";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DisplayName => "Key / value pairs";
|
||||
|
||||
/// <inheritdoc />
|
||||
public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
|
||||
string input,
|
||||
IProgress<ParseProgress>? progress,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(input);
|
||||
|
||||
var lines = TextLines.Split(input);
|
||||
var total = lines.Length;
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var line = lines[i];
|
||||
var lineNumber = i + 1;
|
||||
var processed = i + 1;
|
||||
|
||||
if (!TextLines.IsSkippable(line))
|
||||
{
|
||||
var separatorIndex = line.IndexOfAny(Separators);
|
||||
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
yield return ParseOutcome<ParsedRecord>.Failure(lineNumber, "No '=' or ':' separator found.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var key = line[..separatorIndex].Trim();
|
||||
var value = line[(separatorIndex + 1)..].Trim();
|
||||
|
||||
yield return key.Length == 0
|
||||
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "Key is empty.")
|
||||
: ParseOutcome<ParsedRecord>.Success(
|
||||
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (progress is not null && processed % TextLines.ProgressInterval == 0)
|
||||
{
|
||||
progress.Report(new ParseProgress(processed, total));
|
||||
}
|
||||
|
||||
if (processed % TextLines.YieldInterval == 0)
|
||||
{
|
||||
await Task.Yield();
|
||||
}
|
||||
}
|
||||
|
||||
progress?.Report(new ParseProgress(total, total));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AvParser.Core.Parsing.Samples;
|
||||
|
||||
/// <summary>Line-splitting helpers shared by the sample parsers.</summary>
|
||||
internal static class TextLines
|
||||
{
|
||||
/// <summary>How many records to process between progress reports.</summary>
|
||||
internal const int ProgressInterval = 256;
|
||||
|
||||
/// <summary>How many records to process between cooperative yields.</summary>
|
||||
internal const int YieldInterval = 1024;
|
||||
|
||||
/// <summary>Splits input into lines, normalising CRLF and stripping a trailing empty line.</summary>
|
||||
internal static string[] Split(string input)
|
||||
{
|
||||
var lines = input.Split('\n');
|
||||
|
||||
for (var i = 0; i < lines.Length; i++)
|
||||
{
|
||||
lines[i] = lines[i].TrimEnd('\r');
|
||||
}
|
||||
|
||||
// A file that ends with a newline yields a phantom trailing empty line; drop it so
|
||||
// progress totals and line numbers match what the user sees in their editor.
|
||||
return lines is [.., ""] ? lines[..^1] : lines;
|
||||
}
|
||||
|
||||
/// <summary>Blank lines and <c>#</c> comments carry no records.</summary>
|
||||
internal static bool IsSkippable(string line) =>
|
||||
string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart()[0] == '#';
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
namespace AvParser.Core.Settings;
|
||||
|
||||
/// <summary>Theme preference. <see cref="System"/> follows the OS setting.</summary>
|
||||
public enum AppTheme
|
||||
{
|
||||
/// <summary>Follow the operating system.</summary>
|
||||
System = 0,
|
||||
|
||||
/// <summary>Always light.</summary>
|
||||
Light = 1,
|
||||
|
||||
/// <summary>Always dark.</summary>
|
||||
Dark = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything the app remembers between runs. Persisted verbatim as JSON.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A mutable record with defaults on every property: a settings file written by an older
|
||||
/// version must still deserialise, so no property may be required.
|
||||
/// </remarks>
|
||||
public sealed record AppSettings
|
||||
{
|
||||
/// <summary>Chosen theme variant.</summary>
|
||||
public AppTheme Theme { get; init; } = AppTheme.System;
|
||||
|
||||
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
|
||||
public string? LastParserId { get; init; }
|
||||
|
||||
/// <summary>Last main-window width in device-independent pixels.</summary>
|
||||
public double WindowWidth { get; init; } = 1280;
|
||||
|
||||
/// <summary>Last main-window height in device-independent pixels.</summary>
|
||||
public double WindowHeight { get; init; } = 800;
|
||||
|
||||
/// <summary>Whether the main window was maximised on exit.</summary>
|
||||
public bool WindowMaximized { get; init; }
|
||||
|
||||
/// <summary>Minimum Serilog level, as a Serilog level name.</summary>
|
||||
public string MinimumLogLevel { get; init; } = "Information";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace AvParser.Core.Settings;
|
||||
|
||||
/// <summary>Reads and persists <see cref="AppSettings"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Update"/> is fire-and-forget on purpose: writes are debounced by the
|
||||
/// implementation so that dragging a window or flicking a toggle does not hit the disk
|
||||
/// on every change. Call <see cref="FlushAsync"/> on shutdown to force the pending write out.
|
||||
/// </remarks>
|
||||
public interface ISettingsService
|
||||
{
|
||||
/// <summary>The current in-memory settings. Never <see langword="null"/>.</summary>
|
||||
AppSettings Current { get; }
|
||||
|
||||
/// <summary>Fires after <see cref="Current"/> changes, including the initial load.</summary>
|
||||
IObservable<AppSettings> Changes { get; }
|
||||
|
||||
/// <summary>Applies a change and schedules a debounced save.</summary>
|
||||
void Update(Func<AppSettings, AppSettings> mutate);
|
||||
|
||||
/// <summary>Writes any pending change immediately.</summary>
|
||||
Task FlushAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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() { }
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.Infrastructure</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<!-- Signal<T> / operators. The UI-free half of the ReactiveUI stack — no Avalonia here. -->
|
||||
<PackageReference Include="ReactiveUI.Primitives" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Settings;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AvParser.Infrastructure.DependencyInjection;
|
||||
|
||||
/// <summary>Composition root for the infrastructure layer.</summary>
|
||||
public static class InfrastructureServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers filesystem paths and the persisted settings service.</summary>
|
||||
/// <param name="services">The collection to add to.</param>
|
||||
/// <param name="paths">
|
||||
/// Explicit paths, or <see langword="null"/> to use the current user's application-data folder.
|
||||
/// Tests pass a temp directory here.
|
||||
/// </param>
|
||||
public static IServiceCollection AddAvParserInfrastructure(this IServiceCollection services, AppPaths? paths = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
var resolved = paths ?? new AppPaths();
|
||||
resolved.EnsureCreated();
|
||||
|
||||
services.AddSingleton<IAppPaths>(resolved);
|
||||
|
||||
// Constructed explicitly rather than by type: the optional ISequencer parameter would
|
||||
// otherwise make the container's constructor choice depend on registration order.
|
||||
services.AddSingleton<ISettingsService>(sp => new JsonSettingsService(
|
||||
sp.GetRequiredService<IAppPaths>(),
|
||||
sp.GetRequiredService<ILogger<JsonSettingsService>>()
|
||||
));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
|
||||
namespace AvParser.Infrastructure.Logging;
|
||||
|
||||
/// <summary>Builds the application's Serilog pipeline.</summary>
|
||||
public static class AppLogging
|
||||
{
|
||||
private const string OutputTemplate =
|
||||
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a console + rolling-file logger writing into <see cref="IAppPaths.LogDirectory"/>.
|
||||
/// </summary>
|
||||
/// <param name="paths">Where log files go.</param>
|
||||
/// <param name="minimumLevel">Serilog level name; unrecognised values fall back to Information.</param>
|
||||
/// <remarks>
|
||||
/// The level is exposed through a <see cref="LoggingLevelSwitch"/> so the Settings page can
|
||||
/// change it at runtime without rebuilding the pipeline or restarting the app.
|
||||
/// </remarks>
|
||||
public static (Logger Logger, LoggingLevelSwitch LevelSwitch) Create(IAppPaths paths, string minimumLevel)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
Directory.CreateDirectory(paths.LogDirectory);
|
||||
|
||||
var levelSwitch = new LoggingLevelSwitch(ParseLevel(minimumLevel));
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.MinimumLevel.ControlledBy(levelSwitch)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console(outputTemplate: OutputTemplate)
|
||||
.WriteTo.File(
|
||||
Path.Combine(paths.LogDirectory, "avparser-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
outputTemplate: OutputTemplate
|
||||
)
|
||||
.CreateLogger();
|
||||
|
||||
return (logger, levelSwitch);
|
||||
}
|
||||
|
||||
/// <summary>Parses a Serilog level name, defaulting to <see cref="LogEventLevel.Information"/>.</summary>
|
||||
public static LogEventLevel ParseLevel(string? name) =>
|
||||
Enum.TryParse<LogEventLevel>(name, ignoreCase: true, out var level) ? level : LogEventLevel.Information;
|
||||
|
||||
/// <summary>The level names offered in the Settings page, ordered from most to least verbose.</summary>
|
||||
public static IReadOnlyList<string> AvailableLevels { get; } =
|
||||
[
|
||||
nameof(LogEventLevel.Verbose),
|
||||
nameof(LogEventLevel.Debug),
|
||||
nameof(LogEventLevel.Information),
|
||||
nameof(LogEventLevel.Warning),
|
||||
nameof(LogEventLevel.Error),
|
||||
nameof(LogEventLevel.Fatal),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using AvParser.Core.Settings;
|
||||
|
||||
namespace AvParser.Infrastructure.Settings;
|
||||
|
||||
/// <summary>Source-generated serialiser metadata for <see cref="AppSettings"/>.</summary>
|
||||
/// <remarks>Keeps settings IO reflection-free, which matters if the app is ever trimmed or AOT-published.</remarks>
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = true,
|
||||
UseStringEnumConverter = true,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase
|
||||
)]
|
||||
[JsonSerializable(typeof(AppSettings))]
|
||||
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,136 @@
|
||||
using System.Text.Json;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using ReactiveUI.Primitives.Extensions;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.Infrastructure.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Persists <see cref="AppSettings"/> to a JSON file, debouncing writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Window resizes and slider drags produce a burst of updates; writing each one would hammer
|
||||
/// the disk for no benefit. Updates are coalesced over <see cref="SaveDebounce"/> and the
|
||||
/// final state is written atomically (temp file + move) so a crash mid-write cannot leave a
|
||||
/// truncated settings file behind.
|
||||
/// </remarks>
|
||||
public sealed class JsonSettingsService : ISettingsService, IDisposable
|
||||
{
|
||||
/// <summary>How long to wait for the update burst to settle before writing.</summary>
|
||||
public static readonly TimeSpan SaveDebounce = TimeSpan.FromSeconds(1);
|
||||
|
||||
private readonly IAppPaths _paths;
|
||||
private readonly ILogger<JsonSettingsService> _logger;
|
||||
private readonly BehaviorSignal<AppSettings> _current;
|
||||
private readonly Signal<AppSettings> _saveRequests = new();
|
||||
private readonly IDisposable _saveSubscription;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly Lock _gate = new();
|
||||
|
||||
/// <summary>Loads settings from disk, falling back to defaults on any problem.</summary>
|
||||
public JsonSettingsService(IAppPaths paths, ILogger<JsonSettingsService> logger, ISequencer? saveScheduler = null)
|
||||
{
|
||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
|
||||
_current = new BehaviorSignal<AppSettings>(Load());
|
||||
|
||||
_saveSubscription = _saveRequests
|
||||
.Throttle(SaveDebounce, saveScheduler ?? TaskPoolSequencer.Instance)
|
||||
.Subscribe(settings => _ = SaveAsync(settings, CancellationToken.None));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public AppSettings Current => _current.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IObservable<AppSettings> Changes => _current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Update(Func<AppSettings, AppSettings> mutate)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(mutate);
|
||||
|
||||
AppSettings next;
|
||||
lock (_gate)
|
||||
{
|
||||
var previous = _current.Value;
|
||||
next = mutate(previous) ?? throw new InvalidOperationException("Mutation returned null settings.");
|
||||
|
||||
if (next == previous)
|
||||
{
|
||||
return; // records compare by value: a no-op edit must not trigger a write
|
||||
}
|
||||
|
||||
_current.OnNext(next);
|
||||
}
|
||||
|
||||
_saveRequests.OnNext(next);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task FlushAsync(CancellationToken cancellationToken = default) =>
|
||||
SaveAsync(_current.Value, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_saveSubscription.Dispose();
|
||||
_saveRequests.Dispose();
|
||||
_current.Dispose();
|
||||
_writeLock.Dispose();
|
||||
}
|
||||
|
||||
private AppSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_paths.SettingsFile))
|
||||
{
|
||||
return new AppSettings();
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_paths.SettingsFile);
|
||||
return JsonSerializer.Deserialize(json, AppSettingsJsonContext.Default.AppSettings) ?? new AppSettings();
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
|
||||
{
|
||||
// A corrupt or unreadable settings file must never stop the app from starting.
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Could not read settings from {Path}; falling back to defaults",
|
||||
_paths.SettingsFile
|
||||
);
|
||||
return new AppSettings();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(_paths.SettingsFile)!);
|
||||
|
||||
var temp = _paths.SettingsFile + ".tmp";
|
||||
var json = JsonSerializer.Serialize(settings, AppSettingsJsonContext.Default.AppSettings);
|
||||
|
||||
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
|
||||
File.Move(temp, _paths.SettingsFile, overwrite: true);
|
||||
|
||||
_logger.LogDebug("Settings written to {Path}", _paths.SettingsFile);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
_logger.LogWarning(ex, "Could not write settings to {Path}", _paths.SettingsFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace AvParser.Infrastructure.Storage;
|
||||
|
||||
/// <summary>Resolves the per-user directories the app writes to.</summary>
|
||||
/// <remarks>
|
||||
/// An interface rather than a static helper so tests can redirect everything into a temp
|
||||
/// folder instead of scribbling in the developer's real profile.
|
||||
/// </remarks>
|
||||
public interface IAppPaths
|
||||
{
|
||||
/// <summary>Root of the per-user data directory. Created on demand.</summary>
|
||||
string DataDirectory { get; }
|
||||
|
||||
/// <summary>Full path of the settings file.</summary>
|
||||
string SettingsFile { get; }
|
||||
|
||||
/// <summary>Directory holding rolling log files.</summary>
|
||||
string LogDirectory { get; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Uses <see cref="Environment.SpecialFolder.ApplicationData"/>, which maps to
|
||||
/// <c>%APPDATA%</c> on Windows and <c>~/.config</c> on Linux/macOS.
|
||||
/// </remarks>
|
||||
public sealed class AppPaths : IAppPaths
|
||||
{
|
||||
private const string FolderName = "AvParser";
|
||||
|
||||
/// <summary>Creates paths under the current user's application-data directory.</summary>
|
||||
public AppPaths()
|
||||
: this(
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(
|
||||
Environment.SpecialFolder.ApplicationData,
|
||||
Environment.SpecialFolderOption.Create
|
||||
),
|
||||
FolderName
|
||||
)
|
||||
) { }
|
||||
|
||||
/// <summary>Creates paths under an explicit root. Used by tests.</summary>
|
||||
public AppPaths(string dataDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
|
||||
DataDirectory = dataDirectory;
|
||||
SettingsFile = Path.Combine(dataDirectory, "settings.json");
|
||||
LogDirectory = Path.Combine(dataDirectory, "logs");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string SettingsFile { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public string LogDirectory { get; }
|
||||
|
||||
/// <summary>Creates every directory this instance points at.</summary>
|
||||
public void EnsureCreated()
|
||||
{
|
||||
Directory.CreateDirectory(DataDirectory);
|
||||
Directory.CreateDirectory(LogDirectory);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<RootNamespace>AvParser.UI</RootNamespace>
|
||||
<!-- A class library, not an exe: the headless test project references this directly and
|
||||
builds real views without dragging in Program.cs, Serilog or the DI container. -->
|
||||
<AvaloniaNameGeneratorIsEnabled>true</AvaloniaNameGeneratorIsEnabled>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
|
||||
<ProjectReference Include="..\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" />
|
||||
<PackageReference Include="ReactiveUI.SourceGenerators" PrivateAssets="all" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="Semi.Avalonia" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
|
||||
namespace AvParser.UI.Converters;
|
||||
|
||||
/// <summary>Small one-way converters used by the views.</summary>
|
||||
public static class AppConverters
|
||||
{
|
||||
/// <summary>Collection counts to a boolean, for showing a panel only when it has content.</summary>
|
||||
public static readonly FuncValueConverter<int, bool> IsPositive = new(static count => count > 0);
|
||||
|
||||
/// <summary>Inverts a boolean, for enabling a control while a command is idle.</summary>
|
||||
public static readonly FuncValueConverter<bool, bool> Not = new(static value => !value);
|
||||
|
||||
/// <summary>Formats a 0..1 fraction as a whole-number percentage.</summary>
|
||||
public static readonly FuncValueConverter<double, string> Percent = new(static value =>
|
||||
value.ToString("P0", CultureInfo.CurrentCulture)
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an icon key from <c>Styles/Icons.axaml</c> to the geometry it names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lets view models refer to icons by a plain string instead of holding
|
||||
/// <see cref="Geometry"/> instances, which keeps them trivially constructible in tests.
|
||||
/// </remarks>
|
||||
public static readonly FuncValueConverter<string?, Geometry?> IconKeyToGeometry = new(static key =>
|
||||
key is not null && Application.Current is { } app && app.TryFindResource(key, out var resource)
|
||||
? resource as Geometry
|
||||
: null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Services;
|
||||
using AvParser.UI.ViewModels;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace AvParser.UI.DependencyInjection;
|
||||
|
||||
/// <summary>Composition root for the presentation layer.</summary>
|
||||
public static class UiServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers the view locator, shell services and every page.</summary>
|
||||
/// <remarks>
|
||||
/// Pages are registered twice on purpose: once under their concrete type (so tests and other
|
||||
/// pages can ask for one specifically) and once under <see cref="PageViewModel"/> in the order
|
||||
/// they should appear in the navigation rail.
|
||||
/// </remarks>
|
||||
public static IServiceCollection AddAvParserUI(this IServiceCollection services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddSingleton<ViewLocator>();
|
||||
services.AddSingleton<IThemeService, ThemeService>();
|
||||
services.AddSingleton<INavigationService, NavigationService>();
|
||||
|
||||
services.AddSingleton<DashboardViewModel>();
|
||||
services.AddSingleton<ParseViewModel>(static sp => new ParseViewModel(
|
||||
sp.GetRequiredService<IParserCatalog>(),
|
||||
sp.GetRequiredService<ISettingsService>(),
|
||||
sp.GetRequiredService<ILogger<ParseViewModel>>()
|
||||
));
|
||||
services.AddSingleton<SettingsViewModel>(static sp => new SettingsViewModel(
|
||||
sp.GetRequiredService<ISettingsService>(),
|
||||
sp.GetRequiredService<IThemeService>(),
|
||||
sp.GetRequiredService<IAppPaths>(),
|
||||
sp.GetRequiredService<LoggingLevelSwitch>()
|
||||
));
|
||||
services.AddSingleton<AboutViewModel>();
|
||||
|
||||
// Order here is the order of the navigation rail; the first entry is the landing page.
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<DashboardViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<ParseViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<SettingsViewModel>());
|
||||
services.AddSingleton<PageViewModel>(static sp => sp.GetRequiredService<AboutViewModel>());
|
||||
|
||||
services.AddSingleton<ShellViewModel>(static sp => new ShellViewModel(
|
||||
sp.GetRequiredService<INavigationService>(),
|
||||
sp.GetRequiredService<IThemeService>()
|
||||
));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using AvParser.UI.ViewModels;
|
||||
|
||||
namespace AvParser.UI.Navigation;
|
||||
|
||||
/// <summary>Drives which page the shell shows, and keeps a back stack.</summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not ReactiveUI's <see cref="ReactiveUI.RoutingState"/>: that requires every page
|
||||
/// to implement <c>IRoutableViewModel</c> and resolves views through Splat's locator, which would
|
||||
/// reintroduce a second dependency-resolution path alongside <c>Microsoft.Extensions.DependencyInjection</c>.
|
||||
/// This interface resolves nothing itself — pages are injected — so it is testable without Avalonia.
|
||||
/// </remarks>
|
||||
public interface INavigationService
|
||||
{
|
||||
/// <summary>Every top-level destination, in the order they appear in the rail.</summary>
|
||||
IReadOnlyList<PageViewModel> Pages { get; }
|
||||
|
||||
/// <summary>The page currently displayed.</summary>
|
||||
PageViewModel Current { get; }
|
||||
|
||||
/// <summary>Emits the current page, starting with the present value.</summary>
|
||||
IObservable<PageViewModel> CurrentChanges { get; }
|
||||
|
||||
/// <summary>Emits whether <see cref="GoBack"/> would do anything.</summary>
|
||||
IObservable<bool> CanGoBack { get; }
|
||||
|
||||
/// <summary>Navigates to an already-resolved page, pushing the previous one onto the back stack.</summary>
|
||||
void NavigateTo(PageViewModel page);
|
||||
|
||||
/// <summary>Navigates to the registered page of the given type.</summary>
|
||||
/// <exception cref="InvalidOperationException">No page of that type is registered.</exception>
|
||||
void NavigateTo<TPage>()
|
||||
where TPage : PageViewModel;
|
||||
|
||||
/// <summary>Pops the back stack. Does nothing when the stack is empty.</summary>
|
||||
void GoBack();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.UI.Navigation;
|
||||
|
||||
/// <inheritdoc cref="INavigationService" />
|
||||
public sealed class NavigationService : INavigationService, IDisposable
|
||||
{
|
||||
private readonly Stack<PageViewModel> _backStack = new();
|
||||
private readonly BehaviorSignal<PageViewModel> _current;
|
||||
private readonly BehaviorSignal<bool> _canGoBack = new(false);
|
||||
|
||||
/// <summary>Creates the service over the pages the container resolved.</summary>
|
||||
/// <param name="pages">Registration order becomes rail order; the first page is the landing page.</param>
|
||||
/// <exception cref="ArgumentException">No pages were registered.</exception>
|
||||
public NavigationService(IEnumerable<PageViewModel> pages)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pages);
|
||||
|
||||
Pages = pages.ToArray();
|
||||
|
||||
if (Pages.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("At least one page must be registered.", nameof(pages));
|
||||
}
|
||||
|
||||
_current = new BehaviorSignal<PageViewModel>(Pages[0]);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<PageViewModel> Pages { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public PageViewModel Current => _current.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IObservable<PageViewModel> CurrentChanges => _current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IObservable<bool> CanGoBack => _canGoBack;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NavigateTo(PageViewModel page)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(page);
|
||||
|
||||
if (ReferenceEquals(page, _current.Value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_backStack.Push(_current.Value);
|
||||
_current.OnNext(page);
|
||||
_canGoBack.OnNext(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void NavigateTo<TPage>()
|
||||
where TPage : PageViewModel
|
||||
{
|
||||
var page =
|
||||
Pages.OfType<TPage>().FirstOrDefault()
|
||||
?? throw new InvalidOperationException($"No page of type {typeof(TPage).Name} is registered.");
|
||||
|
||||
NavigateTo(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void GoBack()
|
||||
{
|
||||
if (!_backStack.TryPop(out var previous))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_current.OnNext(previous);
|
||||
_canGoBack.OnNext(_backStack.Count > 0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_current.Dispose();
|
||||
_canGoBack.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AvParser.UI.Responsive;
|
||||
|
||||
/// <summary>Width class the shell adapts to. Named after the WinUI/Material size classes.</summary>
|
||||
public enum Breakpoint
|
||||
{
|
||||
/// <summary>Phone-width or a heavily shrunk window: navigation becomes an overlay drawer.</summary>
|
||||
Compact,
|
||||
|
||||
/// <summary>Tablet-width: navigation collapses to an icon rail.</summary>
|
||||
Medium,
|
||||
|
||||
/// <summary>Desktop-width: navigation is a full inline sidebar with labels.</summary>
|
||||
Expanded,
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using ReactiveUI.Primitives;
|
||||
|
||||
namespace AvParser.UI.Responsive;
|
||||
|
||||
/// <summary>
|
||||
/// Breakpoint engine: watches a control's width and projects a <see cref="Breakpoint"/> onto
|
||||
/// both an attached property and <c>:compact</c> / <c>:medium</c> / <c>:expanded</c> pseudoclasses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Avalonia has no <c>AdaptiveTrigger</c> or <c>VisualStateManager</c>, and no CSS media queries.
|
||||
/// The three primitives that do exist are <see cref="Visual.BoundsProperty"/> (observable),
|
||||
/// pseudoclasses (settable from code, usable in selectors) and <see cref="SplitView"/>. This class
|
||||
/// wires the first onto the second so that XAML can style by width the way CSS would.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Enable it with <c>r:ResponsiveLayout.IsEnabled="True"</c> on the shell, then select on
|
||||
/// <c>UserControl.shell:compact ...</c> in styles.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ResponsiveLayout
|
||||
{
|
||||
/// <summary>Widths below this are <see cref="Breakpoint.Compact"/>.</summary>
|
||||
public const double MediumMinWidth = 720d;
|
||||
|
||||
/// <summary>Widths at or above this are <see cref="Breakpoint.Expanded"/>.</summary>
|
||||
public const double ExpandedMinWidth = 1100d;
|
||||
|
||||
/// <summary>
|
||||
/// Deadband applied to the band the control is already in, in device-independent pixels.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Without it, dragging a resize grip across a boundary makes the layout flap between two
|
||||
/// states on every pixel of jitter.
|
||||
/// </remarks>
|
||||
public const double Hysteresis = 24d;
|
||||
|
||||
/// <summary>Set to <see langword="true"/> to start observing width on this control.</summary>
|
||||
public static readonly AttachedProperty<bool> IsEnabledProperty = AvaloniaProperty.RegisterAttached<Control, bool>(
|
||||
"IsEnabled",
|
||||
typeof(ResponsiveLayout)
|
||||
);
|
||||
|
||||
/// <summary>The current breakpoint. Read-only in practice: written by this class.</summary>
|
||||
/// <remarks>Inherits down the visual tree, so any descendant can bind to it.</remarks>
|
||||
public static readonly AttachedProperty<Breakpoint> BreakpointProperty = AvaloniaProperty.RegisterAttached<
|
||||
Control,
|
||||
Breakpoint
|
||||
>("Breakpoint", typeof(ResponsiveLayout), Breakpoint.Expanded, inherits: true);
|
||||
|
||||
private static readonly AttachedProperty<IDisposable?> SubscriptionProperty = AvaloniaProperty.RegisterAttached<
|
||||
Control,
|
||||
IDisposable?
|
||||
>("Subscription", typeof(ResponsiveLayout));
|
||||
|
||||
static ResponsiveLayout() => IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged);
|
||||
|
||||
/// <summary>Gets whether width observation is enabled.</summary>
|
||||
public static bool GetIsEnabled(Control control) => control.GetValue(IsEnabledProperty);
|
||||
|
||||
/// <summary>Enables or disables width observation.</summary>
|
||||
public static void SetIsEnabled(Control control, bool value) => control.SetValue(IsEnabledProperty, value);
|
||||
|
||||
/// <summary>Gets the control's current breakpoint.</summary>
|
||||
public static Breakpoint GetBreakpoint(Control control) => control.GetValue(BreakpointProperty);
|
||||
|
||||
/// <summary>
|
||||
/// Maps a width to a breakpoint, widening whichever band <paramref name="current"/> is already
|
||||
/// in by <see cref="Hysteresis"/>.
|
||||
/// </summary>
|
||||
public static Breakpoint Classify(double width, Breakpoint current = Breakpoint.Expanded)
|
||||
{
|
||||
var mediumThreshold = current == Breakpoint.Compact ? MediumMinWidth + Hysteresis : MediumMinWidth;
|
||||
var expandedThreshold = current == Breakpoint.Expanded ? ExpandedMinWidth - Hysteresis : ExpandedMinWidth;
|
||||
|
||||
if (width >= expandedThreshold)
|
||||
{
|
||||
return Breakpoint.Expanded;
|
||||
}
|
||||
|
||||
return width >= mediumThreshold ? Breakpoint.Medium : Breakpoint.Compact;
|
||||
}
|
||||
|
||||
/// <summary>Writes the breakpoint and its pseudoclasses onto a control.</summary>
|
||||
/// <remarks>Public so headless tests can drive a control without a live layout pass.</remarks>
|
||||
public static void Apply(Control control, Breakpoint breakpoint)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(control);
|
||||
|
||||
control.SetValue(BreakpointProperty, breakpoint);
|
||||
|
||||
var pseudoClasses = (IPseudoClasses)control.Classes;
|
||||
pseudoClasses.Set(":compact", breakpoint is Breakpoint.Compact);
|
||||
pseudoClasses.Set(":medium", breakpoint is Breakpoint.Medium);
|
||||
pseudoClasses.Set(":expanded", breakpoint is Breakpoint.Expanded);
|
||||
}
|
||||
|
||||
private static void OnIsEnabledChanged(Control control, AvaloniaPropertyChangedEventArgs args)
|
||||
{
|
||||
control.GetValue(SubscriptionProperty)?.Dispose();
|
||||
control.SetValue(SubscriptionProperty, null);
|
||||
|
||||
if (!args.GetNewValue<bool>())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var subscription = control
|
||||
.GetObservable(Visual.BoundsProperty)
|
||||
.Select(static bounds => bounds.Width)
|
||||
.Where(static width => width > 0)
|
||||
.Select(width => Classify(width, GetBreakpoint(control)))
|
||||
.DistinctUntilChanged()
|
||||
.Subscribe(breakpoint => Apply(control, breakpoint));
|
||||
|
||||
control.SetValue(SubscriptionProperty, subscription);
|
||||
control.DetachedFromVisualTree += OnDetached;
|
||||
}
|
||||
|
||||
private static void OnDetached(object? sender, VisualTreeAttachmentEventArgs args)
|
||||
{
|
||||
if (sender is not Control control)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
control.DetachedFromVisualTree -= OnDetached;
|
||||
control.GetValue(SubscriptionProperty)?.Dispose();
|
||||
control.SetValue(SubscriptionProperty, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using AvParser.Core.Settings;
|
||||
|
||||
namespace AvParser.UI.Services;
|
||||
|
||||
/// <summary>Applies and persists the light/dark/system theme choice.</summary>
|
||||
public interface IThemeService
|
||||
{
|
||||
/// <summary>The theme currently in effect.</summary>
|
||||
AppTheme Current { get; }
|
||||
|
||||
/// <summary>Emits the theme, starting with the present value.</summary>
|
||||
IObservable<AppTheme> Changes { get; }
|
||||
|
||||
/// <summary>Applies a theme to the running application and persists the choice.</summary>
|
||||
void Apply(AppTheme theme);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Styling;
|
||||
using AvParser.Core.Settings;
|
||||
using ReactiveUI.Primitives.Signals;
|
||||
|
||||
namespace AvParser.UI.Services;
|
||||
|
||||
/// <inheritdoc cref="IThemeService" />
|
||||
public sealed class ThemeService : IThemeService, IDisposable
|
||||
{
|
||||
private readonly ISettingsService _settings;
|
||||
private readonly BehaviorSignal<AppTheme> _current;
|
||||
|
||||
/// <summary>Restores the persisted theme and applies it immediately.</summary>
|
||||
public ThemeService(ISettingsService settings)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_current = new BehaviorSignal<AppTheme>(settings.Current.Theme);
|
||||
|
||||
ApplyToApplication(settings.Current.Theme);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public AppTheme Current => _current.Value;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IObservable<AppTheme> Changes => _current;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Apply(AppTheme theme)
|
||||
{
|
||||
if (theme == _current.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyToApplication(theme);
|
||||
_current.OnNext(theme);
|
||||
_settings.Update(current => current with { Theme = theme });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => _current.Dispose();
|
||||
|
||||
/// <summary>Maps the app's theme enum onto Avalonia's variant.</summary>
|
||||
public static ThemeVariant ToVariant(AppTheme theme) =>
|
||||
theme switch
|
||||
{
|
||||
AppTheme.Light => ThemeVariant.Light,
|
||||
AppTheme.Dark => ThemeVariant.Dark,
|
||||
_ => ThemeVariant.Default,
|
||||
};
|
||||
|
||||
private static void ApplyToApplication(AppTheme theme)
|
||||
{
|
||||
// Null under unit tests that never start Avalonia — theme state still tracks correctly.
|
||||
if (Application.Current is { } app)
|
||||
{
|
||||
app.RequestedThemeVariant = ToVariant(theme);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!-- App-level control modifiers. Everything references tokens; no literal colours here. -->
|
||||
|
||||
<Style Selector="TextBlock.display">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeDisplay}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.title">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeTitle}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.subtitle">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeSubtitle}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.muted">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextMutedBrush}" />
|
||||
<Setter Property="TextWrapping" Value="Wrap" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.caption">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeCaption}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextMutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.mono">
|
||||
<Setter Property="FontFamily" Value="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace" />
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
|
||||
</Style>
|
||||
|
||||
<!-- Card: the only container used for grouped content across the app. -->
|
||||
<Style Selector="Border.card">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceRaisedBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusLg}" />
|
||||
<Setter Property="Padding" Value="{DynamicResource CardPadding}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card.interactive">
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.15" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.card.interactive:pointerover">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Small inline chip, used for parsed field values and status pills. -->
|
||||
<Style Selector="Border.chip">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceSunkenBrush}" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusSm}" />
|
||||
<Setter Property="Padding" Value="6,2" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.chip.danger">
|
||||
<Setter Property="Background" Value="{DynamicResource AppDangerSoftBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Border.chip.accent">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Icon glyph. Paths inherit the surrounding foreground so they follow the theme. -->
|
||||
<Style Selector="PathIcon.glyph">
|
||||
<Setter Property="Width" Value="{DynamicResource IconSize}" />
|
||||
<Setter Property="Height" Value="{DynamicResource IconSize}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Accent and destructive buttons.
|
||||
|
||||
Written against our own tokens rather than reusing Semi's `Primary` / `Danger` classes: those
|
||||
are tied to Semi's palette, so the app would have two sources of accent colour that drift
|
||||
apart. Index.axaml is included after SemiTheme, so these setters win.
|
||||
-->
|
||||
<Style Selector="Button.primary">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}" />
|
||||
<Setter Property="Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusMd}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="#FFFFFF" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusMd}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Opacity" Value="0.88" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.primary:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.destructive">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppDangerBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppDangerBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="14,8" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusMd}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.destructive /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource AppDangerBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.destructive:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppDangerSoftBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.destructive:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Opacity" Value="0.4" />
|
||||
</Style>
|
||||
|
||||
<!-- Square, chrome-less button that holds a single glyph. -->
|
||||
<Style Selector="Button.icon">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusMd}" />
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextMutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button.icon:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceSunkenBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource AppTextBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="Separator.section">
|
||||
<Setter Property="Background" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="Height" Value="1" />
|
||||
<Setter Property="Margin" Value="0,4" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,45 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Icons as StreamGeometry rather than an icon font: no extra package, no font-fallback
|
||||
surprises on Linux, and they recolour with the theme like any other Path.
|
||||
All are drawn on a 24x24 grid.
|
||||
-->
|
||||
|
||||
<StreamGeometry x:Key="IconHome">M12 3 2 12h3v8h6v-6h2v6h6v-8h3L12 3z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconDocument">
|
||||
M6 2h9l5 5v15H6V2zm8 1.5V8h4.5L14 3.5zM8 12h8v1.6H8V12zm0 3.4h8V17H8v-1.6z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconSettings">
|
||||
M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7zm9.4 3.5c0 .5 0 .9-.1 1.3l2 1.6-1.9 3.3-2.4-1a7.6 7.6 0 0 1-2.2 1.3l-.4 2.5h-3.8l-.4-2.5a7.6 7.6 0 0 1-2.2-1.3l-2.4 1-1.9-3.3 2-1.6a7.7 7.7 0 0 1 0-2.6l-2-1.6L5.6 5.8l2.4 1a7.6 7.6 0 0 1 2.2-1.3l.4-2.5h3.8l.4 2.5a7.6 7.6 0 0 1 2.2 1.3l2.4-1 1.9 3.3-2 1.6c.1.4.1.8.1 1.3z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconInfo">
|
||||
M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconMenu">M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconBack">M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconPlay">M8 5v14l11-7z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconStop">M6.5 6.5h11v11h-11z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconBroom">
|
||||
M4 20h16v-1.6H4V20zm3.6-3.6h8.8l-1.2-5.2-2-1V4.4h-2.4v5.8l-2 1-1.2 5.2z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconSun">
|
||||
M12 7.2a4.8 4.8 0 1 0 0 9.6 4.8 4.8 0 0 0 0-9.6zM11 1.4h2v3.2h-2V1.4zm0 18h2v3.2h-2v-3.2zM1.4 11h3.2v2H1.4v-2zm18 0h3.2v2h-3.2v-2zM4.3 5.7 5.7 4.3l2.2 2.3-1.4 1.4-2.2-2.3zm11.8 11.9 1.4-1.4 2.3 2.2-1.4 1.4-2.3-2.2zM18.3 4.3l1.4 1.4-2.3 2.2-1.4-1.4 2.3-2.2zM4.3 18.3l2.2-2.3 1.4 1.4-2.2 2.3-1.4-1.4z
|
||||
</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconMoon">M12.4 3a9 9 0 1 0 8.6 11.2A7 7 0 0 1 12.4 3z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconAlert">M12 2 1 21h22L12 2zm1 14.2h-2v-2h2v2zm0-3.8h-2V8.6h2v3.8z</StreamGeometry>
|
||||
|
||||
<StreamGeometry x:Key="IconSparkle">
|
||||
M12 2.5 13.9 9l6.6 1.9-6.6 1.9L12 19.4l-1.9-6.6L3.5 11 10.1 9 12 2.5z
|
||||
</StreamGeometry>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,19 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Single entry point for the app's look. Hosts include exactly this one file:
|
||||
<StyleInclude Source="avares://AvParser.UI/Styles/Index.axaml" />
|
||||
which keeps App.axaml and the headless test app from drifting apart.
|
||||
-->
|
||||
|
||||
<Styles.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ResourceInclude Source="avares://AvParser.UI/Styles/Tokens.axaml" />
|
||||
<ResourceInclude Source="avares://AvParser.UI/Styles/Icons.axaml" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Styles.Resources>
|
||||
|
||||
<StyleInclude Source="avares://AvParser.UI/Styles/Controls.axaml" />
|
||||
<StyleInclude Source="avares://AvParser.UI/Styles/Shell.axaml" />
|
||||
</Styles>
|
||||
@@ -0,0 +1,109 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Breakpoint styling.
|
||||
|
||||
Responsive.cs sets the :compact / :medium / :expanded pseudoclasses on the shell as its
|
||||
width changes, and these selectors read them the way CSS media queries would.
|
||||
|
||||
Note `:is(UserControl).shell` rather than `UserControl.shell`: an Avalonia type
|
||||
selector matches the EXACT type, and ShellView derives from ReactiveUserControl<T>, so
|
||||
`UserControl.shell` silently matches nothing and every rule below quietly does nothing.
|
||||
|
||||
Division of labour, and the reason for it:
|
||||
* SplitView.DisplayMode and IsPaneOpen are BOUND to the view model. A style Setter loses
|
||||
to a local value permanently, so the first hamburger click would freeze any style that
|
||||
also wrote those properties.
|
||||
* Everything purely visual — pane widths, label visibility, padding — lives here.
|
||||
-->
|
||||
|
||||
<!-- ===== Base ===== -->
|
||||
|
||||
<Style Selector=":is(UserControl).shell">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell SplitView#NavPane">
|
||||
<Setter Property="OpenPaneLength" Value="{DynamicResource NavPaneWidth}" />
|
||||
<Setter Property="CompactPaneLength" Value="{DynamicResource NavRailWidth}" />
|
||||
<Setter Property="PaneBackground" Value="{DynamicResource AppNavBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell Border#TitleBar">
|
||||
<Setter Property="Background" Value="{DynamicResource AppSurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource AppBorderBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="Padding" Value="{DynamicResource ToolbarPadding}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell Border#PaneHeader">
|
||||
<Setter Property="Padding" Value="{DynamicResource ToolbarPadding}" />
|
||||
<Setter Property="MinHeight" Value="48" />
|
||||
</Style>
|
||||
|
||||
<!-- Navigation entries: a flat list, accent-tinted when selected. -->
|
||||
|
||||
<Style Selector="ListBox.nav">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.nav ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,9" />
|
||||
<Setter Property="Margin" Value="0,1" />
|
||||
<Setter Property="CornerRadius" Value="{DynamicResource RadiusMd}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource AppTextMutedBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="ListBox.nav ListBoxItem:selected /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource AppAccentSoftBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{DynamicResource AppAccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector="TextBlock.navLabel">
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeBody}" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.12" Easing="CubicEaseOut" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<!-- ===== Expanded: full sidebar with labels ===== -->
|
||||
|
||||
<Style Selector=":is(UserControl).shell:expanded Button#PaneToggle">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell:expanded Border#PageHost">
|
||||
<Setter Property="Padding" Value="{DynamicResource PagePadding}" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== Medium: icon rail, labels collapse away ===== -->
|
||||
|
||||
<Style Selector=":is(UserControl).shell:medium TextBlock.navLabel">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell:medium TextBlock#PaneTitle">
|
||||
<Setter Property="IsVisible" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell:medium ListBox.nav ListBoxItem">
|
||||
<Setter Property="Padding" Value="10,9" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell:medium Border#PageHost">
|
||||
<Setter Property="Padding" Value="{DynamicResource PagePadding}" />
|
||||
</Style>
|
||||
|
||||
<!-- ===== Compact: overlay drawer, tighter chrome ===== -->
|
||||
|
||||
<Style Selector=":is(UserControl).shell:compact Border#PageHost">
|
||||
<Setter Property="Padding" Value="{DynamicResource PagePaddingCompact}" />
|
||||
</Style>
|
||||
|
||||
<Style Selector=":is(UserControl).shell:compact TextBlock#ShellTitle">
|
||||
<Setter Property="FontSize" Value="{DynamicResource FontSizeSubtitle}" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -0,0 +1,71 @@
|
||||
<ResourceDictionary xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<!--
|
||||
Design tokens. Every colour and every spacing value in the app comes from here, so that
|
||||
"make the UI denser" or "retune the dark palette" is one edit rather than a grep.
|
||||
Semi.Avalonia supplies the control themes; these are the app-level semantics on top.
|
||||
-->
|
||||
|
||||
<ResourceDictionary.ThemeDictionaries>
|
||||
<ResourceDictionary x:Key="Light">
|
||||
<!-- The page sits one step below the cards: a white card on a white page needs its border
|
||||
to do all the work, and a 1px hairline is not enough separation to read as a card. -->
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#EBEEF2" />
|
||||
<SolidColorBrush x:Key="AppSurfaceSunkenBrush" Color="#DFE3EA" />
|
||||
<SolidColorBrush x:Key="AppSurfaceRaisedBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="AppNavBrush" Color="#FFFFFF" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#D2D7DF" />
|
||||
<SolidColorBrush x:Key="AppTextBrush" Color="#12141A" />
|
||||
<SolidColorBrush x:Key="AppTextMutedBrush" Color="#6B7280" />
|
||||
<SolidColorBrush x:Key="AppAccentBrush" Color="#2563EB" />
|
||||
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#E8EFFD" />
|
||||
<SolidColorBrush x:Key="AppDangerBrush" Color="#DC2626" />
|
||||
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#FDECEC" />
|
||||
<SolidColorBrush x:Key="AppSuccessBrush" Color="#15803D" />
|
||||
</ResourceDictionary>
|
||||
|
||||
<ResourceDictionary x:Key="Dark">
|
||||
<SolidColorBrush x:Key="AppSurfaceBrush" Color="#131519" />
|
||||
<SolidColorBrush x:Key="AppSurfaceSunkenBrush" Color="#0D0F12" />
|
||||
<SolidColorBrush x:Key="AppSurfaceRaisedBrush" Color="#1E2128" />
|
||||
<SolidColorBrush x:Key="AppNavBrush" Color="#0F1114" />
|
||||
<SolidColorBrush x:Key="AppBorderBrush" Color="#2C303A" />
|
||||
<SolidColorBrush x:Key="AppTextBrush" Color="#EDEFF3" />
|
||||
<SolidColorBrush x:Key="AppTextMutedBrush" Color="#9AA1AE" />
|
||||
<SolidColorBrush x:Key="AppAccentBrush" Color="#5B8DEF" />
|
||||
<SolidColorBrush x:Key="AppAccentSoftBrush" Color="#1B2740" />
|
||||
<SolidColorBrush x:Key="AppDangerBrush" Color="#F87171" />
|
||||
<SolidColorBrush x:Key="AppDangerSoftBrush" Color="#33191B" />
|
||||
<SolidColorBrush x:Key="AppSuccessBrush" Color="#4ADE80" />
|
||||
</ResourceDictionary>
|
||||
</ResourceDictionary.ThemeDictionaries>
|
||||
|
||||
<!-- Spacing scale, in device-independent pixels. -->
|
||||
<x:Double x:Key="SpacingXs">4</x:Double>
|
||||
<x:Double x:Key="SpacingSm">8</x:Double>
|
||||
<x:Double x:Key="SpacingMd">12</x:Double>
|
||||
<x:Double x:Key="SpacingLg">16</x:Double>
|
||||
<x:Double x:Key="SpacingXl">24</x:Double>
|
||||
<x:Double x:Key="Spacing2Xl">32</x:Double>
|
||||
|
||||
<Thickness x:Key="PagePadding">24</Thickness>
|
||||
<Thickness x:Key="PagePaddingCompact">12</Thickness>
|
||||
<Thickness x:Key="CardPadding">16</Thickness>
|
||||
<Thickness x:Key="ToolbarPadding">16,10</Thickness>
|
||||
|
||||
<!-- Corner radii. -->
|
||||
<CornerRadius x:Key="RadiusSm">4</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusMd">8</CornerRadius>
|
||||
<CornerRadius x:Key="RadiusLg">12</CornerRadius>
|
||||
|
||||
<!-- Typography. -->
|
||||
<x:Double x:Key="FontSizeDisplay">28</x:Double>
|
||||
<x:Double x:Key="FontSizeTitle">20</x:Double>
|
||||
<x:Double x:Key="FontSizeSubtitle">15</x:Double>
|
||||
<x:Double x:Key="FontSizeBody">13</x:Double>
|
||||
<x:Double x:Key="FontSizeCaption">12</x:Double>
|
||||
|
||||
<!-- Shell metrics. Kept here so the breakpoint styles and the tests agree on one source. -->
|
||||
<x:Double x:Key="NavPaneWidth">248</x:Double>
|
||||
<x:Double x:Key="NavRailWidth">56</x:Double>
|
||||
<x:Double x:Key="IconSize">16</x:Double>
|
||||
</ResourceDictionary>
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Templates;
|
||||
using AvParser.UI.ViewModels;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace AvParser.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Maps a view model to its view by naming convention and builds it through the container.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <c>AvParser.UI.ViewModels.SettingsViewModel</c> resolves to <c>AvParser.UI.Views.SettingsView</c>.
|
||||
/// The namespace substitution must run before the type-name one, otherwise
|
||||
/// <c>ViewModels.XViewModel</c> becomes <c>Views.XView</c> only by accident.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Registered from code rather than declared in <c>App.axaml</c>: a XAML-declared instance would
|
||||
/// need a parameterless constructor and could never see <see cref="IServiceProvider"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ViewLocator(IServiceProvider services) : IDataTemplate
|
||||
{
|
||||
private static readonly ConcurrentDictionary<Type, Type?> ViewTypeCache = new();
|
||||
|
||||
private readonly IServiceProvider _services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Match(object? data) => data is ViewModelBase;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Control Build(object? param)
|
||||
{
|
||||
if (param is null)
|
||||
{
|
||||
return new TextBlock { Text = "(no view model)" };
|
||||
}
|
||||
|
||||
var viewModelType = param.GetType();
|
||||
var viewType = ViewTypeCache.GetOrAdd(viewModelType, ResolveViewType);
|
||||
|
||||
if (viewType is null)
|
||||
{
|
||||
return new TextBlock { Text = $"View not found for {viewModelType.FullName}" };
|
||||
}
|
||||
|
||||
// Prefer a registered view so views may take injected services; fall back to activation
|
||||
// so that adding a view does not force a DI registration.
|
||||
var view =
|
||||
_services.GetService(viewType) as Control
|
||||
?? (Control)ActivatorUtilities.CreateInstance(_services, viewType);
|
||||
|
||||
view.DataContext = param;
|
||||
return view;
|
||||
}
|
||||
|
||||
private static Type? ResolveViewType(Type viewModelType)
|
||||
{
|
||||
var name = viewModelType
|
||||
.FullName!.Replace(".ViewModels.", ".Views.", StringComparison.Ordinal)
|
||||
.Replace("ViewModel", "View", StringComparison.Ordinal);
|
||||
|
||||
// A type whose name matches neither half of the convention would otherwise resolve to
|
||||
// itself, and the locator would try to activate the view model as its own view.
|
||||
if (string.Equals(name, viewModelType.FullName, StringComparison.Ordinal))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var candidate = viewModelType.Assembly.GetType(name);
|
||||
|
||||
// A name collision with a non-Control type must read as "no view", not as a cast error
|
||||
// deep inside Build.
|
||||
return candidate is not null && typeof(Control).IsAssignableFrom(candidate) ? candidate : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Reflection;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>One row of the "built with" table.</summary>
|
||||
/// <param name="Name">Component name.</param>
|
||||
/// <param name="Detail">Version or a one-line note.</param>
|
||||
public sealed record ComponentInfo(string Name, string Detail);
|
||||
|
||||
/// <summary>Version, runtime and stack information.</summary>
|
||||
public sealed class AboutViewModel : PageViewModel
|
||||
{
|
||||
/// <summary>Creates the page.</summary>
|
||||
public AboutViewModel(IAppPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
var assembly = typeof(AboutViewModel).Assembly;
|
||||
|
||||
Version =
|
||||
assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? assembly.GetName().Version?.ToString()
|
||||
?? "unknown";
|
||||
|
||||
// Source-built informational versions carry a "+<commit sha>" suffix; the hash is noise here.
|
||||
var plus = Version.IndexOf('+', StringComparison.Ordinal);
|
||||
if (plus > 0)
|
||||
{
|
||||
Version = Version[..plus];
|
||||
}
|
||||
|
||||
DataDirectory = paths.DataDirectory;
|
||||
LogDirectory = paths.LogDirectory;
|
||||
|
||||
Components =
|
||||
[
|
||||
new ComponentInfo(".NET", Environment.Version.ToString()),
|
||||
new ComponentInfo("Operating system", Environment.OSVersion.ToString()),
|
||||
new ComponentInfo("Avalonia", VersionOf("Avalonia.Base")),
|
||||
new ComponentInfo("ReactiveUI", VersionOf("ReactiveUI")),
|
||||
new ComponentInfo("Semi.Avalonia", VersionOf("Semi.Avalonia")),
|
||||
];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Title => "About";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconInfo";
|
||||
|
||||
/// <summary>Informational version of the UI assembly.</summary>
|
||||
public string Version { get; }
|
||||
|
||||
/// <summary>Root of the per-user data directory.</summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>Where rolling log files are written.</summary>
|
||||
public string LogDirectory { get; }
|
||||
|
||||
/// <summary>The stack this build is running on.</summary>
|
||||
public IReadOnlyList<ComponentInfo> Components { get; }
|
||||
|
||||
private static string VersionOf(string assemblyName)
|
||||
{
|
||||
var assembly = AppDomain
|
||||
.CurrentDomain.GetAssemblies()
|
||||
.FirstOrDefault(a => string.Equals(a.GetName().Name, assemblyName, StringComparison.Ordinal));
|
||||
|
||||
return assembly?.GetName().Version?.ToString() ?? "not loaded";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using AvParser.Core.Parsing;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Navigation;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>Landing page: what is registered, where data lives, and shortcuts into the app.</summary>
|
||||
public sealed class DashboardViewModel : PageViewModel
|
||||
{
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
/// <summary>Creates the dashboard.</summary>
|
||||
/// <param name="catalog">Registered parsers, shown as cards.</param>
|
||||
/// <param name="paths">Where the app writes settings and logs.</param>
|
||||
/// <param name="services">
|
||||
/// Used to resolve <see cref="INavigationService"/> at click time rather than at construction
|
||||
/// time. Injecting it directly would be a cycle: the navigation service is built from every
|
||||
/// page, so a page cannot also depend on it up front.
|
||||
/// </param>
|
||||
public DashboardViewModel(IParserCatalog catalog, IAppPaths paths, IServiceProvider services)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(catalog);
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
_services = services ?? throw new ArgumentNullException(nameof(services));
|
||||
|
||||
Parsers = catalog.Parsers;
|
||||
DataDirectory = paths.DataDirectory;
|
||||
|
||||
GoToParseCommand = ReactiveCommand.Create(() => Navigate<ParseViewModel>());
|
||||
GoToSettingsCommand = ReactiveCommand.Create(() => Navigate<SettingsViewModel>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Title => "Dashboard";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconHome";
|
||||
|
||||
/// <summary>Registered parsers, shown as cards.</summary>
|
||||
public IReadOnlyList<ITextParser> Parsers { get; }
|
||||
|
||||
/// <summary>Where settings and logs are written.</summary>
|
||||
public string DataDirectory { get; }
|
||||
|
||||
/// <summary>Jumps to the Parse page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToParseCommand { get; }
|
||||
|
||||
/// <summary>Jumps to the Settings page.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoToSettingsCommand { get; }
|
||||
|
||||
private void Navigate<TPage>()
|
||||
where TPage : PageViewModel => _services.GetRequiredService<INavigationService>().NavigateTo<TPage>();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.Infrastructure.Logging;
|
||||
using AvParser.Infrastructure.Storage;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.Services;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
using Serilog.Core;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>Theme, logging level and where the app keeps its files.</summary>
|
||||
public partial class SettingsViewModel : PageViewModel
|
||||
{
|
||||
private readonly ISettingsService _settings;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly LoggingLevelSwitch _levelSwitch;
|
||||
|
||||
/// <summary>Selected theme. Applied immediately, not on an OK button.</summary>
|
||||
[Reactive]
|
||||
public partial AppTheme SelectedTheme { get; set; }
|
||||
|
||||
/// <summary>Selected Serilog level name. Takes effect immediately.</summary>
|
||||
[Reactive]
|
||||
public partial string SelectedLogLevel { get; set; }
|
||||
|
||||
/// <summary>Creates the page.</summary>
|
||||
public SettingsViewModel(
|
||||
ISettingsService settings,
|
||||
IThemeService theme,
|
||||
IAppPaths paths,
|
||||
LoggingLevelSwitch levelSwitch,
|
||||
ISequencer? mainThread = null
|
||||
)
|
||||
{
|
||||
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
|
||||
_theme = theme ?? throw new ArgumentNullException(nameof(theme));
|
||||
_levelSwitch = levelSwitch ?? throw new ArgumentNullException(nameof(levelSwitch));
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
|
||||
SettingsFile = paths.SettingsFile;
|
||||
LogDirectory = paths.LogDirectory;
|
||||
|
||||
SelectedTheme = theme.Current;
|
||||
SelectedLogLevel = settings.Current.MinimumLogLevel;
|
||||
|
||||
this.WhenAnyValue(x => x.SelectedTheme).ObserveOn(scheduler).Subscribe(_theme.Apply);
|
||||
|
||||
this.WhenAnyValue(x => x.SelectedLogLevel)
|
||||
.Where(static level => !string.IsNullOrEmpty(level))
|
||||
.DistinctUntilChanged()
|
||||
.Subscribe(ApplyLogLevel);
|
||||
|
||||
// Keep the radio group honest when the theme is flipped from the title-bar button.
|
||||
theme.Changes.ObserveOn(scheduler).Subscribe(value => SelectedTheme = value);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Title => "Settings";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string IconKey => "IconSettings";
|
||||
|
||||
/// <summary>Theme options offered by the radio group.</summary>
|
||||
public IReadOnlyList<AppTheme> Themes { get; } = [AppTheme.System, AppTheme.Light, AppTheme.Dark];
|
||||
|
||||
/// <summary>Serilog level names, most to least verbose.</summary>
|
||||
public IReadOnlyList<string> LogLevels => AppLogging.AvailableLevels;
|
||||
|
||||
/// <summary>Full path of the settings file.</summary>
|
||||
public string SettingsFile { get; }
|
||||
|
||||
/// <summary>Directory holding rolling log files.</summary>
|
||||
public string LogDirectory { get; }
|
||||
|
||||
/// <summary>Width in pixels at which the shell switches from compact to the icon rail.</summary>
|
||||
public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth;
|
||||
|
||||
/// <summary>Width in pixels at which the shell switches to the full sidebar.</summary>
|
||||
public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth;
|
||||
|
||||
private void ApplyLogLevel(string level)
|
||||
{
|
||||
_levelSwitch.MinimumLevel = AppLogging.ParseLevel(level);
|
||||
_settings.Update(current => current with { MinimumLogLevel = level });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Avalonia.Controls;
|
||||
using AvParser.Core.Settings;
|
||||
using AvParser.UI.Navigation;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.Services;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Primitives;
|
||||
using ReactiveUI.Primitives.Concurrency;
|
||||
using ReactiveUI.SourceGenerators;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>The application shell: navigation rail, title bar and the hosted page.</summary>
|
||||
/// <remarks>
|
||||
/// Pane state lives here rather than in a style setter. A style <c>Setter</c> loses to a local
|
||||
/// value permanently, so the first hamburger click would otherwise freeze the breakpoint styles.
|
||||
/// Styles own <c>DisplayMode</c> and the pane lengths; this view model owns <see cref="IsPaneOpen"/>.
|
||||
/// </remarks>
|
||||
public partial class ShellViewModel : ViewModelBase
|
||||
{
|
||||
private readonly INavigationService _navigation;
|
||||
private readonly IThemeService _theme;
|
||||
private readonly ObservableAsPropertyHelper<SplitViewDisplayMode> _paneDisplayMode;
|
||||
private readonly ObservableAsPropertyHelper<PageViewModel> _currentPage;
|
||||
private readonly ObservableAsPropertyHelper<string> _title;
|
||||
private readonly ObservableAsPropertyHelper<bool> _canGoBack;
|
||||
private readonly ObservableAsPropertyHelper<string> _themeIconKey;
|
||||
|
||||
/// <summary>Current width class. Written by the view as the window resizes.</summary>
|
||||
[Reactive]
|
||||
public partial Breakpoint Breakpoint { get; set; }
|
||||
|
||||
/// <summary>Whether the navigation pane is open.</summary>
|
||||
[Reactive]
|
||||
public partial bool IsPaneOpen { get; set; }
|
||||
|
||||
/// <summary>The rail's selected entry. Two-way bound to the navigation list.</summary>
|
||||
[Reactive]
|
||||
public partial PageViewModel SelectedPage { get; set; }
|
||||
|
||||
/// <summary>Creates the shell over the registered pages.</summary>
|
||||
/// <param name="navigation">Page stack.</param>
|
||||
/// <param name="theme">Theme switching.</param>
|
||||
/// <param name="mainThread">
|
||||
/// Scheduler for derived properties. Tests pass <see cref="ImmediateSequencer.Instance"/>
|
||||
/// so assertions can run without a dispatcher.
|
||||
/// </param>
|
||||
public ShellViewModel(INavigationService navigation, IThemeService theme, ISequencer? mainThread = null)
|
||||
{
|
||||
_navigation = navigation ?? throw new ArgumentNullException(nameof(navigation));
|
||||
_theme = theme ?? throw new ArgumentNullException(nameof(theme));
|
||||
|
||||
var scheduler = mainThread ?? RxSchedulers.MainThreadScheduler;
|
||||
|
||||
Breakpoint = Breakpoint.Expanded;
|
||||
IsPaneOpen = true;
|
||||
SelectedPage = navigation.Current;
|
||||
|
||||
_currentPage = navigation.CurrentChanges.ToProperty(this, nameof(CurrentPage), navigation.Current, scheduler);
|
||||
|
||||
_title = navigation
|
||||
.CurrentChanges.Select(static page => page.Title)
|
||||
.ToProperty(this, nameof(Title), navigation.Current.Title, scheduler);
|
||||
|
||||
_canGoBack = navigation.CanGoBack.ToProperty(this, nameof(CanGoBack), false, scheduler);
|
||||
|
||||
_paneDisplayMode = this.WhenAnyValue(x => x.Breakpoint)
|
||||
.Select(static breakpoint =>
|
||||
breakpoint switch
|
||||
{
|
||||
Breakpoint.Expanded => SplitViewDisplayMode.Inline,
|
||||
Breakpoint.Medium => SplitViewDisplayMode.CompactInline,
|
||||
_ => SplitViewDisplayMode.Overlay,
|
||||
}
|
||||
)
|
||||
.ToProperty(this, nameof(PaneDisplayMode), SplitViewDisplayMode.Inline, scheduler);
|
||||
|
||||
_themeIconKey = theme
|
||||
.Changes.Select(static value => value == AppTheme.Dark ? "IconSun" : "IconMoon")
|
||||
.ToProperty(this, nameof(ThemeIconKey), "IconMoon", scheduler);
|
||||
|
||||
// Crossing a breakpoint resets the pane to that layout's natural state. A manual toggle
|
||||
// then overrides it until the next breakpoint change.
|
||||
this.WhenAnyValue(x => x.Breakpoint)
|
||||
.Select(static breakpoint => breakpoint == Breakpoint.Expanded)
|
||||
.Subscribe(open => IsPaneOpen = open);
|
||||
|
||||
// Rail selection drives navigation...
|
||||
this.WhenAnyValue(x => x.SelectedPage).Subscribe(_navigation.NavigateTo);
|
||||
|
||||
// ...and navigation from anywhere else keeps the rail's highlight honest.
|
||||
navigation.CurrentChanges.Subscribe(page => SelectedPage = page);
|
||||
|
||||
// On a compact layout the pane is a modal drawer: picking a destination dismisses it.
|
||||
this.WhenAnyValue(x => x.SelectedPage)
|
||||
.Where(_ => Breakpoint is Breakpoint.Compact)
|
||||
.Subscribe(_ => IsPaneOpen = false);
|
||||
|
||||
TogglePaneCommand = ReactiveCommand.Create(() => IsPaneOpen = !IsPaneOpen, outputScheduler: scheduler);
|
||||
|
||||
GoBackCommand = ReactiveCommand.Create(navigation.GoBack, navigation.CanGoBack, scheduler);
|
||||
|
||||
ToggleThemeCommand = ReactiveCommand.Create(
|
||||
() => _theme.Apply(_theme.Current == AppTheme.Dark ? AppTheme.Light : AppTheme.Dark),
|
||||
outputScheduler: scheduler
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>Every top-level destination, for the rail.</summary>
|
||||
public IReadOnlyList<PageViewModel> Pages => _navigation.Pages;
|
||||
|
||||
/// <summary>The page hosted in the content area.</summary>
|
||||
public PageViewModel CurrentPage => _currentPage.Value;
|
||||
|
||||
/// <summary>Title of the current page.</summary>
|
||||
public string Title => _title.Value;
|
||||
|
||||
/// <summary>Whether the back button is enabled.</summary>
|
||||
public bool CanGoBack => _canGoBack.Value;
|
||||
|
||||
/// <summary>How the navigation pane is laid out at the current breakpoint.</summary>
|
||||
public SplitViewDisplayMode PaneDisplayMode => _paneDisplayMode.Value;
|
||||
|
||||
/// <summary>Icon key for the theme toggle: a sun in dark mode, a moon in light mode.</summary>
|
||||
public string ThemeIconKey => _themeIconKey.Value;
|
||||
|
||||
/// <summary>Opens or closes the navigation pane.</summary>
|
||||
public ReactiveCommand<RxVoid, bool> TogglePaneCommand { get; }
|
||||
|
||||
/// <summary>Pops the navigation back stack.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> GoBackCommand { get; }
|
||||
|
||||
/// <summary>Flips between the light and dark theme.</summary>
|
||||
public ReactiveCommand<RxVoid, RxVoid> ToggleThemeCommand { get; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ReactiveUI;
|
||||
|
||||
namespace AvParser.UI.ViewModels;
|
||||
|
||||
/// <summary>Base for every view model in the app.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="IActivatableViewModel"/> gives views a <c>WhenActivated</c> block whose
|
||||
/// subscriptions are torn down when the view leaves the visual tree — the standard fix for
|
||||
/// view models outliving their views and leaking handlers.
|
||||
/// </remarks>
|
||||
public abstract class ViewModelBase : ReactiveObject, IActivatableViewModel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public ViewModelActivator Activator { get; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A view model that appears as a top-level destination in the navigation rail.</summary>
|
||||
public abstract class PageViewModel : ViewModelBase
|
||||
{
|
||||
/// <summary>Label shown in the sidebar and the title bar.</summary>
|
||||
public abstract string Title { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Key of a <c>StreamGeometry</c> in <c>Styles/Icons.axaml</c> used as the rail icon.
|
||||
/// </summary>
|
||||
public abstract string IconKey { get; }
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
x:Class="AvParser.UI.Views.AboutView"
|
||||
x:DataType="vm:AboutViewModel"
|
||||
>
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" MaxWidth="720" HorizontalAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="display" Text="AvParser" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Border Classes="chip accent">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Version}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="Built with" />
|
||||
<ItemsControl ItemsSource="{Binding Components}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ComponentInfo">
|
||||
<Grid ColumnDefinitions="180,*" Margin="0,3">
|
||||
<TextBlock Grid.Column="0" Classes="caption" Text="{Binding Name}" VerticalAlignment="Center" />
|
||||
<SelectableTextBlock Grid.Column="1" Classes="mono" Text="{Binding Detail}" TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="On disk" />
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="DATA" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="LOGS" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Version and environment information.</summary>
|
||||
public partial class AboutView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public AboutView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core"
|
||||
x:Class="AvParser.UI.Views.DashboardView"
|
||||
x:DataType="vm:DashboardViewModel"
|
||||
>
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="24" MaxWidth="1040" HorizontalAlignment="Left">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="display" Text="AvParser" />
|
||||
<TextBlock
|
||||
Classes="muted"
|
||||
MaxWidth="640"
|
||||
Text="A parser shell with an adaptive layout. Drag the window narrower to watch the navigation collapse to an icon rail and then to an overlay drawer."
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="Registered parsers" />
|
||||
<ItemsControl ItemsSource="{Binding Parsers}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="parsing:ITextParser">
|
||||
<Border Classes="card interactive" Width="320" Margin="0,0,12,12">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="subtitle" Text="{Binding DisplayName}" />
|
||||
<Border Classes="chip accent" HorizontalAlignment="Left">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Id}" />
|
||||
</Border>
|
||||
<TextBlock Classes="muted" Text="{Binding Description}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="Get started" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<Button Classes="primary" Command="{Binding GoToParseCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
|
||||
<TextBlock Text="Open the parser" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Command="{Binding GoToSettingsCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconSettings}" />
|
||||
<TextBlock Text="Settings" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="DATA DIRECTORY" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding DataDirectory}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Landing page.</summary>
|
||||
public partial class DashboardView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public DashboardView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Window
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:views="clr-namespace:AvParser.UI.Views"
|
||||
x:Class="AvParser.UI.Views.MainWindow"
|
||||
x:DataType="vm:ShellViewModel"
|
||||
Title="AvParser"
|
||||
Width="1280"
|
||||
Height="800"
|
||||
MinWidth="360"
|
||||
MinHeight="480"
|
||||
Background="{DynamicResource AppSurfaceBrush}"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
>
|
||||
<!-- Thin by design: the shell is a UserControl so headless tests can measure and arrange it
|
||||
at an arbitrary width without going through a window manager. -->
|
||||
<views:ShellView />
|
||||
</Window>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>The application window. Hosts <see cref="ShellView"/> and nothing else.</summary>
|
||||
public partial class MainWindow : Window
|
||||
{
|
||||
/// <summary>Creates the window.</summary>
|
||||
public MainWindow() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:AvParser.UI.Converters"
|
||||
xmlns:parsing="clr-namespace:AvParser.Core.Parsing;assembly=AvParser.Core"
|
||||
x:Class="AvParser.UI.Views.ParseView"
|
||||
x:DataType="vm:ParseViewModel"
|
||||
>
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<!-- ===== Toolbar ===== -->
|
||||
<Border Grid.Row="0" Classes="card" Margin="0,0,0,12">
|
||||
<StackPanel Spacing="12">
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Spacing="4" Margin="0,0,16,8" MinWidth="240">
|
||||
<TextBlock Classes="caption" Text="PARSER" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding Parsers}"
|
||||
SelectedItem="{Binding SelectedParser}"
|
||||
IsEnabled="{Binding IsBusy, Converter={x:Static conv:AppConverters.Not}}"
|
||||
HorizontalAlignment="Stretch"
|
||||
>
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="parsing:ITextParser">
|
||||
<TextBlock Text="{Binding DisplayName}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="4" Margin="0,0,0,8">
|
||||
<TextBlock Classes="caption" Text="ACTIONS" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="primary" Command="{Binding ParseCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconPlay}" />
|
||||
<TextBlock Text="Parse" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Classes="destructive" Command="{Binding CancelCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconStop}" />
|
||||
<TextBlock Text="Cancel" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
<Button Command="{Binding LoadSampleCommand}" ToolTip.Tip="Fill the input with a small example">
|
||||
<TextBlock Text="Sample" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
Command="{Binding GenerateLargeSampleCommand}"
|
||||
ToolTip.Tip="Generate 50 000 rows, so progress and cancellation are observable"
|
||||
>
|
||||
<TextBlock Text="50k rows" />
|
||||
</Button>
|
||||
|
||||
<Button Classes="icon" Command="{Binding ClearCommand}" ToolTip.Tip="Clear input and results">
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconBroom}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
|
||||
<TextBlock Classes="muted" Text="{Binding SelectedParser.Description}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ===== Progress and status ===== -->
|
||||
<StackPanel Grid.Row="1" Spacing="8" Margin="0,0,0,12">
|
||||
<ProgressBar
|
||||
Minimum="0"
|
||||
Maximum="1"
|
||||
Value="{Binding Progress}"
|
||||
IsIndeterminate="False"
|
||||
IsVisible="{Binding IsBusy}"
|
||||
Height="4"
|
||||
/>
|
||||
<TextBlock
|
||||
Classes="muted"
|
||||
Text="{Binding StatusMessage}"
|
||||
IsVisible="{Binding StatusMessage, Converter={x:Static ObjectConverters.IsNotNull}}"
|
||||
/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ===== Input and results ===== -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,8,1.4*">
|
||||
<Border Grid.Column="0" Classes="card" Padding="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
>
|
||||
<TextBlock Classes="caption" Text="INPUT" />
|
||||
</Border>
|
||||
<TextBox
|
||||
Text="{Binding InputText}"
|
||||
AcceptsReturn="True"
|
||||
AcceptsTab="True"
|
||||
TextWrapping="NoWrap"
|
||||
PlaceholderText="Paste text here, or press Sample"
|
||||
BorderThickness="0"
|
||||
Background="Transparent"
|
||||
FontFamily="Cascadia Code,Consolas,Menlo,DejaVu Sans Mono,monospace"
|
||||
FontSize="{DynamicResource FontSizeBody}"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto"
|
||||
ScrollViewer.VerticalScrollBarVisibility="Auto"
|
||||
/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<GridSplitter Grid.Column="1" ResizeDirection="Columns" Background="Transparent" />
|
||||
|
||||
<Grid Grid.Column="2" RowDefinitions="*,Auto">
|
||||
<Border Grid.Row="0" Classes="card" Padding="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="caption" Text="RECORDS" VerticalAlignment="Center" />
|
||||
<Border Classes="chip">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Records.Count}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox
|
||||
ItemsSource="{Binding Records}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
SelectionMode="Single"
|
||||
>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="parsing:ParsedRecord">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Classes="chip" VerticalAlignment="Center" MinWidth="44">
|
||||
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
<ItemsControl ItemsSource="{Binding Fields}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="parsing:ParsedField">
|
||||
<Border Classes="chip accent" Margin="0,2,6,2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Classes="caption" Text="{Binding Name}" Opacity="0.7" />
|
||||
<TextBlock Classes="mono caption" Text="{Binding Value}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Border
|
||||
Grid.Row="1"
|
||||
Classes="card"
|
||||
Margin="0,8,0,0"
|
||||
Padding="0"
|
||||
MaxHeight="180"
|
||||
IsVisible="{Binding Errors.Count, Converter={x:Static conv:AppConverters.IsPositive}}"
|
||||
>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border
|
||||
DockPanel.Dock="Top"
|
||||
Padding="16,12"
|
||||
BorderThickness="0,0,0,1"
|
||||
BorderBrush="{DynamicResource AppBorderBrush}"
|
||||
>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{DynamicResource IconAlert}"
|
||||
Foreground="{DynamicResource AppDangerBrush}"
|
||||
VerticalAlignment="Center"
|
||||
/>
|
||||
<TextBlock Classes="caption" Text="ERRORS" VerticalAlignment="Center" />
|
||||
<Border Classes="chip danger">
|
||||
<TextBlock Classes="mono caption" Text="{Binding Errors.Count}" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox ItemsSource="{Binding Errors}" Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="parsing:ParseError">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<Border Classes="chip danger" VerticalAlignment="Center" MinWidth="44">
|
||||
<TextBlock Classes="mono caption" Text="{Binding LineNumber}" HorizontalAlignment="Center" />
|
||||
</Border>
|
||||
<TextBlock Classes="muted" Text="{Binding Message}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Input, toolbar and streamed parse results.</summary>
|
||||
public partial class ParseView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public ParseView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<UserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
x:Class="AvParser.UI.Views.SettingsView"
|
||||
x:DataType="vm:SettingsViewModel"
|
||||
>
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="16" MaxWidth="720" HorizontalAlignment="Left">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Classes="subtitle" Text="Appearance" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="THEME" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding Themes}"
|
||||
SelectedItem="{Binding SelectedTheme}"
|
||||
HorizontalAlignment="Stretch"
|
||||
/>
|
||||
<TextBlock Classes="muted" Text="System follows the operating system's light/dark setting." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="16">
|
||||
<TextBlock Classes="subtitle" Text="Diagnostics" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="MINIMUM LOG LEVEL" />
|
||||
<ComboBox
|
||||
ItemsSource="{Binding LogLevels}"
|
||||
SelectedItem="{Binding SelectedLogLevel}"
|
||||
HorizontalAlignment="Stretch"
|
||||
/>
|
||||
<TextBlock Classes="muted" Text="Applies immediately — no restart needed." />
|
||||
</StackPanel>
|
||||
|
||||
<Separator Classes="section" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="SETTINGS FILE" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding SettingsFile}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="caption" Text="LOG DIRECTORY" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding LogDirectory}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="subtitle" Text="Layout breakpoints" />
|
||||
<TextBlock
|
||||
Classes="muted"
|
||||
Text="Window widths at which the navigation changes shape. Resize the window to see it happen."
|
||||
/>
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" ColumnSpacing="16" RowSpacing="8">
|
||||
<TextBlock Grid.Row="0" Grid.Column="0" Classes="caption" Text="COMPACT" />
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Classes="muted">
|
||||
<Run Text="below" />
|
||||
<Run Text="{Binding MediumBreakpoint}" />
|
||||
<Run Text="px — overlay drawer" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="1" Grid.Column="0" Classes="caption" Text="MEDIUM" />
|
||||
<TextBlock Grid.Row="1" Grid.Column="1" Classes="muted">
|
||||
<Run Text="{Binding MediumBreakpoint}" />
|
||||
<Run Text="–" />
|
||||
<Run Text="{Binding ExpandedBreakpoint}" />
|
||||
<Run Text="px — icon rail" />
|
||||
</TextBlock>
|
||||
|
||||
<TextBlock Grid.Row="2" Grid.Column="0" Classes="caption" Text="EXPANDED" />
|
||||
<TextBlock Grid.Row="2" Grid.Column="1" Classes="muted">
|
||||
<Run Text="from" />
|
||||
<Run Text="{Binding ExpandedBreakpoint}" />
|
||||
<Run Text="px — full sidebar" />
|
||||
</TextBlock>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Theme, logging and paths.</summary>
|
||||
public partial class SettingsView : UserControl
|
||||
{
|
||||
/// <summary>Creates the view.</summary>
|
||||
public SettingsView() => InitializeComponent();
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<rxui:ReactiveUserControl
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:rxui="clr-namespace:ReactiveUI.Avalonia;assembly=ReactiveUI.Avalonia"
|
||||
xmlns:vm="clr-namespace:AvParser.UI.ViewModels"
|
||||
xmlns:conv="clr-namespace:AvParser.UI.Converters"
|
||||
xmlns:r="clr-namespace:AvParser.UI.Responsive"
|
||||
x:TypeArguments="vm:ShellViewModel"
|
||||
x:Class="AvParser.UI.Views.ShellView"
|
||||
x:DataType="vm:ShellViewModel"
|
||||
Classes="shell"
|
||||
r:ResponsiveLayout.IsEnabled="True"
|
||||
>
|
||||
<SplitView x:Name="NavPane" DisplayMode="{Binding PaneDisplayMode}" IsPaneOpen="{Binding IsPaneOpen, Mode=TwoWay}">
|
||||
<!-- ===== Navigation pane ===== -->
|
||||
<SplitView.Pane>
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border x:Name="PaneHeader" DockPanel.Dock="Top">
|
||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{DynamicResource IconSparkle}"
|
||||
Foreground="{DynamicResource AppAccentBrush}"
|
||||
VerticalAlignment="Center"
|
||||
/>
|
||||
<TextBlock x:Name="PaneTitle" Classes="subtitle" Text="AvParser" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<ListBox
|
||||
x:Name="NavList"
|
||||
Classes="nav"
|
||||
ItemsSource="{Binding Pages}"
|
||||
SelectedItem="{Binding SelectedPage, Mode=TwoWay}"
|
||||
>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PageViewModel">
|
||||
<StackPanel Orientation="Horizontal" Spacing="12">
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{Binding IconKey, Converter={x:Static conv:AppConverters.IconKeyToGeometry}}"
|
||||
VerticalAlignment="Center"
|
||||
/>
|
||||
<TextBlock Classes="navLabel" Text="{Binding Title}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
</SplitView.Pane>
|
||||
|
||||
<!-- ===== Content ===== -->
|
||||
<DockPanel LastChildFill="True">
|
||||
<Border x:Name="TitleBar" DockPanel.Dock="Top">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
<Button
|
||||
x:Name="PaneToggle"
|
||||
Grid.Column="0"
|
||||
Classes="icon"
|
||||
Command="{Binding TogglePaneCommand}"
|
||||
ToolTip.Tip="Toggle navigation"
|
||||
Margin="0,0,4,0"
|
||||
>
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconMenu}" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
x:Name="BackButton"
|
||||
Grid.Column="1"
|
||||
Classes="icon"
|
||||
Command="{Binding GoBackCommand}"
|
||||
IsVisible="{Binding CanGoBack}"
|
||||
ToolTip.Tip="Back"
|
||||
Margin="0,0,8,0"
|
||||
>
|
||||
<PathIcon Classes="glyph" Data="{DynamicResource IconBack}" />
|
||||
</Button>
|
||||
|
||||
<TextBlock
|
||||
x:Name="ShellTitle"
|
||||
Grid.Column="2"
|
||||
Classes="title"
|
||||
Text="{Binding Title}"
|
||||
VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
/>
|
||||
|
||||
<Button
|
||||
x:Name="ThemeToggle"
|
||||
Grid.Column="3"
|
||||
Classes="icon"
|
||||
Command="{Binding ToggleThemeCommand}"
|
||||
ToolTip.Tip="Switch light / dark"
|
||||
>
|
||||
<PathIcon
|
||||
Classes="glyph"
|
||||
Data="{Binding ThemeIconKey, Converter={x:Static conv:AppConverters.IconKeyToGeometry}}"
|
||||
/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Border x:Name="PageHost" Background="{DynamicResource AppSurfaceBrush}">
|
||||
<TransitioningContentControl Content="{Binding CurrentPage}">
|
||||
<TransitioningContentControl.PageTransition>
|
||||
<CompositePageTransition>
|
||||
<CrossFade Duration="0:0:0.18" />
|
||||
<PageSlide Duration="0:0:0.18" Orientation="Horizontal" SlideInEasing="CubicEaseOut" />
|
||||
</CompositePageTransition>
|
||||
</TransitioningContentControl.PageTransition>
|
||||
</TransitioningContentControl>
|
||||
</Border>
|
||||
</DockPanel>
|
||||
</SplitView>
|
||||
</rxui:ReactiveUserControl>
|
||||
@@ -0,0 +1,34 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using AvParser.UI.Responsive;
|
||||
using AvParser.UI.ViewModels;
|
||||
using ReactiveUI.Avalonia;
|
||||
using ReactiveUI.Primitives;
|
||||
|
||||
namespace AvParser.UI.Views;
|
||||
|
||||
/// <summary>Hosts the navigation rail, the title bar and the current page.</summary>
|
||||
public partial class ShellView : ReactiveUserControl<ShellViewModel>
|
||||
{
|
||||
/// <summary>Creates the view and starts feeding breakpoint changes to the view model.</summary>
|
||||
public ShellView()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// ResponsiveLayout.IsEnabled (set in XAML) drives the pseudoclasses for styling; this
|
||||
// line is the other half — it hands the same breakpoint to the view model so that pane
|
||||
// state stays testable without a visual tree.
|
||||
this.GetObservable(ResponsiveLayout.BreakpointProperty)
|
||||
.Subscribe(breakpoint =>
|
||||
{
|
||||
if (DataContext is ShellViewModel viewModel)
|
||||
{
|
||||
viewModel.Breakpoint = breakpoint;
|
||||
}
|
||||
});
|
||||
|
||||
DataContextChanged += (_, _) => ViewModel = DataContext as ShellViewModel;
|
||||
}
|
||||
|
||||
private void InitializeComponent() => AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
Reference in New Issue
Block a user