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,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