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,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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user