Scaffold AvParser: Avalonia 12 shell with adaptive layout

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 16:07:08 +03:00
co-authored by Claude Opus 5
commit 3db9d4dfc6
94 changed files with 5966 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"csharpier": {
"version": "1.3.0",
"commands": [
"csharpier"
],
"rollForward": false
}
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 120,
"useTabs": false,
"tabWidth": 4,
"endOfLine": "crlf"
}
+178
View File
@@ -0,0 +1,178 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = crlf
[*.{json,yml,yaml,axaml,xaml,xml,csproj,props,targets,config,slnx}]
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.cs]
max_line_length = 120
#### .NET code style ####
# Usings
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
csharp_using_directive_placement = outside_namespace:warning
# Namespaces
csharp_style_namespace_declarations = file_scoped:warning
# this. qualification
dotnet_style_qualification_for_field = false:warning
dotnet_style_qualification_for_property = false:warning
dotnet_style_qualification_for_method = false:warning
dotnet_style_qualification_for_event = false:warning
# Language keywords over BCL type names
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
# Modifiers
dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning
csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:warning
dotnet_style_readonly_field = true:warning
csharp_prefer_static_local_function = true:warning
# var
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:warning
csharp_style_var_elsewhere = false:suggestion
# Expression-bodied members
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
csharp_style_expression_bodied_properties = when_on_single_line:warning
csharp_style_expression_bodied_accessors = when_on_single_line:warning
csharp_style_expression_bodied_lambdas = when_on_single_line:suggestion
csharp_style_expression_bodied_local_functions = when_on_single_line:suggestion
# Pattern matching and modern syntax
csharp_style_pattern_matching_over_is_with_cast_check = true:warning
csharp_style_pattern_matching_over_as_with_null_check = true:warning
csharp_style_prefer_switch_expression = true:warning
csharp_style_prefer_pattern_matching = true:warning
csharp_style_prefer_not_pattern = true:warning
csharp_style_throw_expression = true:suggestion
csharp_style_conditional_delegate_call = true:warning
csharp_prefer_simple_using_statement = true:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_prefer_range_operator = true:warning
csharp_style_prefer_index_operator = true:warning
csharp_style_inlined_variable_declaration = true:warning
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_prefer_braces = true:suggestion
csharp_style_prefer_top_level_statements = false:suggestion
# Null checking
dotnet_style_coalesce_expression = true:warning
dotnet_style_null_propagation = true:warning
dotnet_style_prefer_is_null_check_over_reference_equality_method = true:warning
# Expression-level preferences
dotnet_style_object_initializer = true:warning
dotnet_style_collection_initializer = true:warning
dotnet_style_prefer_collection_expression = when_types_loosely_match:warning
dotnet_style_explicit_tuple_names = true:warning
dotnet_style_prefer_auto_properties = true:warning
dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
dotnet_style_prefer_conditional_expression_over_return = true:suggestion
dotnet_style_prefer_compound_assignment = true:warning
dotnet_style_prefer_simplified_boolean_expressions = true:warning
dotnet_style_namespace_match_folder = true:warning
# Unnecessary code
dotnet_diagnostic.IDE0005.severity = warning
dotnet_code_quality_unused_parameters = non_public:suggestion
csharp_style_unused_value_expression_statement_preference = discard_variable:suggestion
# Formatting is owned by csharpier (`dotnet csharpier check .`), not by Roslyn.
# The two disagree on wrapping in a few places, and with TreatWarningsAsErrors on that
# disagreement would make a csharpier-formatted file unbuildable. One formatter wins.
dotnet_diagnostic.IDE0055.severity = suggestion
#### Naming ####
dotnet_naming_rule.interfaces_start_with_i.severity = warning
dotnet_naming_rule.interfaces_start_with_i.symbols = interface
dotnet_naming_rule.interfaces_start_with_i.style = begins_with_i
dotnet_naming_rule.types_pascal_case.severity = warning
dotnet_naming_rule.types_pascal_case.symbols = types
dotnet_naming_rule.types_pascal_case.style = pascal_case
dotnet_naming_rule.members_pascal_case.severity = warning
dotnet_naming_rule.members_pascal_case.symbols = non_field_members
dotnet_naming_rule.members_pascal_case.style = pascal_case
dotnet_naming_rule.constants_pascal_case.severity = warning
dotnet_naming_rule.constants_pascal_case.symbols = constants
dotnet_naming_rule.constants_pascal_case.style = pascal_case
dotnet_naming_rule.static_readonly_pascal_case.severity = warning
dotnet_naming_rule.static_readonly_pascal_case.symbols = static_readonly_fields
dotnet_naming_rule.static_readonly_pascal_case.style = pascal_case
dotnet_naming_rule.private_fields_underscore.severity = warning
dotnet_naming_rule.private_fields_underscore.symbols = private_fields
dotnet_naming_rule.private_fields_underscore.style = underscore_camel_case
dotnet_naming_symbols.interface.applicable_kinds = interface
dotnet_naming_symbols.interface.applicable_accessibilities = *
dotnet_naming_symbols.types.applicable_kinds = class,struct,interface,enum,delegate
dotnet_naming_symbols.types.applicable_accessibilities = *
dotnet_naming_symbols.non_field_members.applicable_kinds = property,event,method
dotnet_naming_symbols.non_field_members.applicable_accessibilities = *
dotnet_naming_symbols.constants.applicable_kinds = field
dotnet_naming_symbols.constants.required_modifiers = const
dotnet_naming_symbols.static_readonly_fields.applicable_kinds = field
dotnet_naming_symbols.static_readonly_fields.required_modifiers = static,readonly
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private,private_protected
dotnet_naming_style.begins_with_i.required_prefix = I
dotnet_naming_style.begins_with_i.capitalization = pascal_case
dotnet_naming_style.pascal_case.capitalization = pascal_case
dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
#### Analyzer tuning ####
# LoggerMessage delegates: not worth the ceremony in a desktop shell.
dotnet_diagnostic.CA1848.severity = none
# "Could be static": XAML `{Binding}` only reaches instance members, so view models legitimately
# expose constants as instance properties. A micro-optimisation rule, not a correctness one.
dotnet_diagnostic.CA1822.severity = suggestion
# Static factory methods on generic result types (ParseOutcome<T>.Success) are the
# idiomatic shape; CA1000 predates them and has no useful replacement to offer.
dotnet_diagnostic.CA1000.severity = none
# Localisation of literal strings is out of scope for now.
dotnet_diagnostic.CA1303.severity = none
# Avalonia views intentionally expose collection properties.
dotnet_diagnostic.CA2227.severity = none
dotnet_diagnostic.CA1002.severity = none
# InvariantGlobalization is on; culture-explicit overloads everywhere add noise.
dotnet_diagnostic.CA1304.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1310.severity = suggestion
[tests/**/*.cs]
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA1861.severity = none
dotnet_diagnostic.IDE0058.severity = none
+20
View File
@@ -0,0 +1,20 @@
* text=auto eol=lf
*.sln text eol=crlf
*.slnx text eol=crlf
*.csproj text eol=crlf
*.props text eol=crlf
*.targets text eol=crlf
*.axaml text eol=crlf
*.xaml text eol=crlf
*.cs text eol=crlf diff=csharp
*.ps1 text eol=crlf
*.cmd text eol=crlf
*.sh text eol=lf
*.png binary
*.jpg binary
*.ico binary
*.ttf binary
*.otf binary
*.woff2 binary
+484
View File
@@ -0,0 +1,484 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# Mac bundle stuff
*.dmg
*.app
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+25
View File
@@ -0,0 +1,25 @@
<Solution>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitattributes" />
<File Path=".gitignore" />
<File Path="Directory.Build.props" />
<File Path="Directory.Packages.props" />
<File Path="global.json" />
<File Path="NuGet.config" />
<File Path="README.md" />
<File Path="CLAUDE.md" />
</Folder>
<Folder Name="/src/">
<Project Path="src/AvParser.Core/AvParser.Core.csproj" />
<Project Path="src/AvParser.Infrastructure/AvParser.Infrastructure.csproj" />
<Project Path="src/AvParser.UI/AvParser.UI.csproj" />
<Project Path="src/AvParser.Desktop/AvParser.Desktop.csproj" />
</Folder>
<Folder Name="/tests/">
<File Path="tests/Directory.Build.props" />
<Project Path="tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj" />
<Project Path="tests/AvParser.UI.Tests/AvParser.UI.Tests.csproj" />
<Project Path="tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj" />
</Folder>
</Solution>
+111
View File
@@ -0,0 +1,111 @@
# CLAUDE.md
Конвенции этого репозитория и грабли, на которые здесь уже наступили. Читать до правок.
## Команды
```bash
dotnet build AvParser.slnx -c Release
```
```bash
dotnet test AvParser.slnx -c Release
```
```bash
dotnet csharpier check .
```
```bash
dotnet run --project src/AvParser.Desktop
```
## Слои
`Core ← Infrastructure ← UI ← Desktop`, строго в одну сторону.
- **`AvParser.Core` не ссылается на Avalonia.** Это единственное ограничение, которое здесь
по-настоящему несущее: домен должен запускаться из CLI, worker-сервиса или бенчмарка. Как
только Avalonia станет доступна из домена, кто-нибудь потянется к `Dispatcher.UIThread` или
`IStorageProvider` внутри парсера.
- **`AvParser.UI` — библиотека, а не exe.** Headless-тесты собирают настоящие View, не
подтягивая `Program.cs`, Serilog и контейнер.
- **`AvParser.Desktop` — тонкий composition root.** Логика туда не переезжает.
## Добавить парсер
1. Реализовать `ITextParser` в `Core/Parsing/`.
2. Одна строка в `CoreServiceCollectionExtensions.AddAvParserCore()`.
Всё. `IParserCatalog`, страница Parse и выпадающий список подхватят его сами.
## Добавить страницу
1. Наследник `PageViewModel` в `UI/ViewModels/XxxViewModel.cs` (`Title`, `IconKey`).
2. `UI/Views/XxxView.axaml` — имя обязано соответствовать конвенции `ViewLocator`:
`...ViewModels.XxxViewModel``...Views.XxxView`.
3. Регистрация в `AddAvParserUI()`: конкретным типом **и** как `PageViewModel` — порядок этих
регистраций и есть порядок пунктов в рельсе навигации.
## ReactiveUI 24 (дистрибутив Primitives)
Это не классический ReactiveUI. `System.Reactive` не используется:
| Классика | Здесь |
|---|---|
| `Unit` | `RxVoid` |
| `IScheduler` | `ISequencer` (`ReactiveUI.Primitives.Concurrency`) |
| `Subject<T>` / `BehaviorSubject<T>` | `Signal<T>` / `BehaviorSignal<T>` |
| `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` |
| `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` |
Привычные имена операторов (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`,
`CombineLatest`) **работают** — Primitives отдаёт оба набора. `using ReactiveUI.Primitives;`
нужен ради `Subscribe(Action<T>)`.
**ReactiveUI 24 не инициализируется сама.** Первый `WhenAnyValue` бросит
`InvalidOperationException`, пока не отработал builder. В приложении это делает
`AppBuilder.UseReactiveUI(...)`; в проекте VM-тестов — module initializer
`ReactiveUiBootstrap`. Новый тестовый проект без Avalonia обязан сделать то же самое.
## Конвенции ViewModel
- **Каждая VM принимает `ISequencer? mainThread = null`** и использует его в `outputScheduler:`
и `ToProperty(..., scheduler)`. Именно это делает тесты синхронными: они передают
`ImmediateSequencer.Instance`. Без этого пришлось бы гонять диспетчер.
- У VM с необязательным `ISequencer` регистрация в DI — явная фабрика, а не по типу: иначе
выбор конструктора контейнером зависит от порядка регистраций.
- `[Reactive]` из `ReactiveUI.SourceGenerators` на partial-свойствах; класс — `partial`.
## Грабли, уже оплаченные
- **Селектор типа в Avalonia матчит точный тип.** `UserControl.shell` не матчит `ShellView`
(наследник `ReactiveUserControl<T>`) и молча не делает ничего. Использовать
`:is(UserControl).shell`. Голый `.shell` тоже матчит, но тогда XAML-компилятор не может
вывести тип для `Setter` и падает с AVLN2200.
- **Style-сеттер навсегда проигрывает локальному значению.** Не стилизовать `IsPaneOpen` и
`DisplayMode` — они биндятся во ViewModel.
- **`IPseudoClasses.Set` требует ведущего `:`**.
- **`InvariantGlobalization` обязан быть `false`**: Semi.Avalonia строит `CultureInfo` в
статическом конструкторе и падает целиком.
- **Превьюер рефлексирует безпараметровый статический `BuildAvaloniaApp()`.** Необязательный
параметр ломает его вызов, вторая перегрузка — `AmbiguousMatchException`.
- **Compiled bindings включены по умолчанию** (Avalonia 12): `x:DataType` нужен на каждом
`UserControl` и каждом `DataTemplate`.
- **`Avalonia.Headless.XUnit` 12.x — это xUnit v3**, а не v2. Тестовые проекты — `Exe`.
- **Headless: ручной `Measure`/`Arrange` внутри окна бесполезен** — следующий проход layout
окна вернёт свой размер. Задавать ширину самому `Window`. Но и без окна нельзя: у
открепленного контрола не строится визуальное дерево.
- **csharpier — единственный владелец форматирования** (включая `.axaml` и `.csproj`).
`IDE0055` понижен до suggestion: два форматтера с `TreatWarningsAsErrors` дерутся насмерть.
## Качество
- Центральные версии пакетов — `Directory.Packages.props`. **Никаких `Version=` в csproj**
(иначе NU1008 на restore).
- `TreatWarningsAsErrors` включён; NuGet-advisory (`NU19xx`) выведены из ошибок, чтобы
свежая CVE не роняла сборку кода, который никто не трогал.
- Тестовые послабления анализаторов — в `tests/Directory.Build.props`, не в самих тестах.
## Что осталось абстрактным
Домен — заглушка. `IParser<TInput, TOutput>` + `DelimitedTextParser` + `KeyValueTextParser`
существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика,
демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`.
+46
View File
@@ -0,0 +1,46 @@
<Project>
<PropertyGroup Label="Framework">
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Must stay false: Semi.Avalonia builds CultureInfo instances for its locale dictionaries
in a static constructor, which throws outright under globalization-invariant mode.
A desktop app also wants real culture-aware number and date formatting. -->
<InvariantGlobalization>false</InvariantGlobalization>
</PropertyGroup>
<PropertyGroup Label="Quality">
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- NuGet audit advisories must not break an otherwise-green build:
a CVE published overnight would fail CI on code nobody touched. -->
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-Recommended</AnalysisLevel>
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<PropertyGroup Label="Build">
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
<AccelerateBuildsInVisualStudio>true</AccelerateBuildsInVisualStudio>
<RestorePackagesWithLockFile>false</RestorePackagesWithLockFile>
<NuGetAudit>true</NuGetAudit>
<NuGetAuditMode>all</NuGetAuditMode>
</PropertyGroup>
<PropertyGroup Label="Metadata">
<Product>AvParser</Product>
<Company>mrleo1nid</Company>
<Authors>mrleo1nid</Authors>
<Version>0.1.0</Version>
<RepositoryUrl>https://gitea.hsrv.site/mrleo1nid/av-parser</RepositoryUrl>
</PropertyGroup>
<PropertyGroup Label="Avalonia">
<!-- Default in Avalonia 12, set explicitly so the intent survives a downgrade. -->
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
</Project>
+63
View File
@@ -0,0 +1,63 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<PropertyGroup Label="Version groups">
<!-- ReactiveUI.Avalonia versions in lockstep with Avalonia, so one knob moves both. -->
<AvaloniaVersion>12.1.1</AvaloniaVersion>
<ReactiveUIVersion>24.1.0</ReactiveUIVersion>
<ReactiveUIPrimitivesVersion>7.1.1</ReactiveUIPrimitivesVersion>
<MicrosoftExtensionsVersion>10.0.11</MicrosoftExtensionsVersion>
</PropertyGroup>
<ItemGroup Label="Avalonia">
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Diagnostics" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersion)" />
<PackageVersion Include="Avalonia.Headless.XUnit" Version="$(AvaloniaVersion)" />
</ItemGroup>
<ItemGroup Label="Theme">
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
</ItemGroup>
<ItemGroup Label="ReactiveUI">
<!-- Avalonia.ReactiveUI is deprecated; ReactiveUI.Avalonia is the maintained successor.
ReactiveUI 24 runs on the Primitives engine: RxVoid replaces Unit, ISequencer replaces
IScheduler, Signal<T> replaces Subject<T>. Classic operator names still resolve. -->
<PackageVersion Include="ReactiveUI" Version="$(ReactiveUIVersion)" />
<PackageVersion Include="ReactiveUI.Avalonia" Version="$(AvaloniaVersion)" />
<PackageVersion Include="ReactiveUI.Primitives" Version="$(ReactiveUIPrimitivesVersion)" />
<PackageVersion Include="ReactiveUI.SourceGenerators" Version="3.2.0" />
<PackageVersion Include="ReactiveUI.Testing" Version="$(ReactiveUIVersion)" />
</ItemGroup>
<ItemGroup Label="Microsoft.Extensions">
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="$(MicrosoftExtensionsVersion)" />
<PackageVersion
Include="Microsoft.Extensions.DependencyInjection.Abstractions"
Version="$(MicrosoftExtensionsVersion)"
/>
<PackageVersion Include="Microsoft.Extensions.Logging" Version="$(MicrosoftExtensionsVersion)" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
</ItemGroup>
<ItemGroup Label="Logging">
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.Extensions.Logging" Version="10.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Shouldly" Version="4.3.0" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
<auditSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</auditSources>
</configuration>
+123
View File
@@ -0,0 +1,123 @@
# AvParser
Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх
Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов.
Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт
`IParser<TInput, TOutput>` и два демо-парсера, чтобы каркас был запускаемым и проверяемым
end-to-end до появления настоящей логики.
---
## Быстрый старт
Нужен .NET SDK **10.0.100** (закреплён в `global.json`).
```bash
dotnet restore AvParser.slnx
```
```bash
dotnet build AvParser.slnx -c Release
```
```bash
dotnet test AvParser.slnx -c Release
```
```bash
dotnet run --project src/AvParser.Desktop
```
Форматирование (csharpier — единственный владелец форматирования, включая `.axaml` и `.csproj`):
```bash
dotnet tool restore && dotnet csharpier check .
```
---
## Структура
```
src/
AvParser.Core домен: IParser, IParserCatalog, модели, демо-парсеры
ноль зависимостей кроме DI.Abstractions — ни Avalonia, ни IO
AvParser.Infrastructure AppPaths, JSON-настройки с debounce, Serilog
AvParser.UI Avalonia class library: App-независимые View, ViewModel,
ResponsiveLayout, дизайн-токены, навигация
AvParser.Desktop WinExe-хост: Program.cs, App.axaml, composition root
tests/
AvParser.Core.Tests парсеры, реестр, отмена, прогресс
AvParser.UI.Tests ViewModel'и без Avalonia
AvParser.UI.HeadlessTests реальное дерево контролов через [AvaloniaFact]
```
Ссылки идут строго в одну сторону: `Core ← Infrastructure ← UI ← Desktop`.
`UI` — библиотека, а не exe, именно чтобы headless-тесты собирали настоящие View, не подтягивая
`Program.cs`, Serilog и контейнер.
---
## Адаптивный layout
В Avalonia нет `AdaptiveTrigger`, `VisualStateManager` и media-queries. Есть три примитива:
наблюдаемый `Visual.Bounds`, псевдоклассы и `SplitView`. `ResponsiveLayout` связывает первое со
вторым — получается CSS-подобная реакция на ширину.
| Брейкпоинт | Ширина окна | Навигация |
|---|---|---|
| Compact | < 720 px | выезжающий drawer поверх контента |
| Medium | 720 1100 px | рельс из одних иконок (56 px) |
| Expanded | ≥ 1100 px | полный сайдбар с подписями (248 px) |
Переключение с гистерезисом в 24 px: без неё перетаскивание края окна заставляет layout
мигать между двумя состояниями на каждом пикселе дрожания.
Разделение обязанностей, которое важно не сломать:
- `SplitView.DisplayMode` и `IsPaneOpen` **биндятся во ViewModel**. Style-сеттер навсегда
проигрывает локальному значению, поэтому первый же клик по гамбургеру заморозил бы любой
стиль, который тоже пишет в эти свойства.
- Всё чисто визуальное — ширины панели, видимость подписей, паддинги — живёт в
`Styles/Shell.axaml`.
Селекторы там написаны как `:is(UserControl).shell`, а не `UserControl.shell`: селектор типа в
Avalonia матчит **точный** тип, а `ShellView` наследуется от `ReactiveUserControl<T>` — обычная
форма молча не сматчилась бы ни с чем. На это есть тест
(`ShellViewTests.The_shell_stylesheet_is_actually_applied`).
---
## Дизайн-токены
Все цвета, отступы, радиусы и типографика — в `Styles/Tokens.axaml`, с отдельными словарями
для Light и Dark. В остальном XAML нет ни одного литерального цвета и ни одного «магического»
отступа, так что перекрасить тему или уплотнить интерфейс — это правка одного файла.
Semi.Avalonia даёт темы контролов; токены — это семантический слой приложения поверх них.
Кнопки `.primary` / `.destructive` описаны своими стилями, а не классами Semi, чтобы акцентный
цвет не разъезжался между двумя палитрами.
---
## Стек
| Пакет | Версия | Заметка |
|---|---|---|
| Avalonia | 12.1.1 | compiled bindings по умолчанию → `x:DataType` обязателен |
| ReactiveUI.Avalonia | 12.1.1 | `Avalonia.ReactiveUI` — deprecated, это его преемник |
| ReactiveUI | 24.1.0 | дистрибутив Primitives: `RxVoid` вместо `Unit`, `ISequencer` вместо `IScheduler` |
| Semi.Avalonia | 12.1.0.1 | темы контролов |
| xUnit | v3 (3.2.2) | `Avalonia.Headless.XUnit` 12.x требует именно v3 |
---
## Что проверить руками
1. Потянуть окно по ширине — сайдбар проходит путь
`полный → только иконки → выезжающий drawer`, без мигания на границах.
2. Переключить тему кнопкой в заголовке и в Settings; перезапустить — выбор сохранился.
3. На странице Parse нажать **50k rows**, затем **Parse** — виден прогресс; **Cancel**
останавливает на середине и пишет, сколько успело разобраться.
4. `F12` в Debug-сборке открывает Avalonia DevTools — там видно, как переключаются
`:compact` / `:medium` / `:expanded`.
Настройки и логи лежат в `%APPDATA%/AvParser` (Windows) или `~/.config/AvParser` (Linux/macOS).
+7
View File
@@ -0,0 +1,7 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
+11
View File
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Core</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- Deliberately the ONLY dependency: the domain must stay hostable from a CLI,
a worker service or a benchmark without dragging in a UI stack. -->
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
</ItemGroup>
</Project>
@@ -0,0 +1,27 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using Microsoft.Extensions.DependencyInjection;
namespace AvParser.Core.DependencyInjection;
/// <summary>Composition root for the domain layer.</summary>
public static class CoreServiceCollectionExtensions
{
/// <summary>
/// Registers every parser plus the catalog that indexes them.
/// </summary>
/// <remarks>
/// Adding a parser is a one-line change here — that is the whole point of the
/// <see cref="ITextParser"/> / <see cref="IParserCatalog"/> split.
/// </remarks>
public static IServiceCollection AddAvParserCore(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.AddSingleton<ITextParser, DelimitedTextParser>();
services.AddSingleton<ITextParser, KeyValueTextParser>();
services.AddSingleton<IParserCatalog, ParserCatalog>();
return services;
}
}
+30
View File
@@ -0,0 +1,30 @@
namespace AvParser.Core.Parsing;
/// <summary>
/// The pluggable unit of the whole application: turns one input into a stream of outcomes.
/// </summary>
/// <remarks>
/// Results are streamed rather than returned as a batch so that the UI can render partial
/// results, report progress and honour cancellation on inputs of arbitrary size.
/// </remarks>
public interface IParser<in TInput, TOutput>
{
/// <summary>Stable identifier used for persistence and lookup. Never localise this.</summary>
string Id { get; }
/// <summary>Human-readable name shown in the UI.</summary>
string DisplayName { get; }
/// <summary>One-line explanation of what this parser accepts.</summary>
string Description { get; }
/// <summary>Cheap structural check — must not throw and must not do IO.</summary>
bool CanParse(TInput input);
/// <summary>Streams one outcome per logical record.</summary>
IAsyncEnumerable<ParseOutcome<TOutput>> ParseAsync(
TInput input,
IProgress<ParseProgress>? progress,
CancellationToken cancellationToken
);
}
@@ -0,0 +1,17 @@
namespace AvParser.Core.Parsing;
/// <summary>Read-only view over every registered text parser.</summary>
public interface IParserCatalog
{
/// <summary>All registered parsers, ordered by <see cref="IParser{TInput,TOutput}.DisplayName"/>.</summary>
IReadOnlyList<ITextParser> Parsers { get; }
/// <summary>The parser used when nothing has been chosen yet.</summary>
ITextParser DefaultParser { get; }
/// <summary>Finds a parser by its stable id; <see langword="null"/> when unknown.</summary>
ITextParser? Find(string? id);
/// <summary>Finds a parser by id, falling back to <see cref="DefaultParser"/>.</summary>
ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser;
}
+10
View File
@@ -0,0 +1,10 @@
namespace AvParser.Core.Parsing;
/// <summary>
/// Closed, non-generic facade over <see cref="IParser{TInput, TOutput}"/>.
/// </summary>
/// <remarks>
/// Open generic interfaces cannot be resolved as <c>IEnumerable&lt;T&gt;</c> by the DI container,
/// so every text-shaped parser implements this closed interface and gets registered under it.
/// </remarks>
public interface ITextParser : IParser<string, ParsedRecord>;
+10
View File
@@ -0,0 +1,10 @@
namespace AvParser.Core.Parsing;
/// <summary>A recoverable problem with a single record. Parsing continues after one of these.</summary>
/// <param name="LineNumber">1-based position of the offending record in the input.</param>
/// <param name="Message">What went wrong, phrased for a user rather than a developer.</param>
public sealed record ParseError(int LineNumber, string Message)
{
/// <inheritdoc />
public override string ToString() => $"Line {LineNumber}: {Message}";
}
+40
View File
@@ -0,0 +1,40 @@
using System.Diagnostics.CodeAnalysis;
namespace AvParser.Core.Parsing;
/// <summary>
/// Result of parsing a single record: either a value or a recoverable <see cref="ParseError"/>.
/// </summary>
/// <remarks>
/// A struct rather than a class hierarchy: parsers emit one of these per line, and on a
/// million-line input the allocation difference is the whole cost of the parse.
/// </remarks>
public readonly record struct ParseOutcome<T>
{
private ParseOutcome(T? value, ParseError? error)
{
Value = value;
Error = error;
}
/// <summary>The parsed value, or <see langword="null"/> when <see cref="IsSuccess"/> is false.</summary>
public T? Value { get; }
/// <summary>The failure, or <see langword="null"/> when <see cref="IsSuccess"/> is true.</summary>
public ParseError? Error { get; }
/// <summary><see langword="true"/> when a value was produced.</summary>
[MemberNotNullWhen(false, nameof(Error))]
public bool IsSuccess => Error is null;
/// <summary>Creates a successful outcome.</summary>
public static ParseOutcome<T> Success(T value) => new(value, null);
/// <summary>Creates a failed outcome.</summary>
public static ParseOutcome<T> Failure(ParseError error) =>
new(default, error ?? throw new ArgumentNullException(nameof(error)));
/// <summary>Creates a failed outcome from its parts.</summary>
public static ParseOutcome<T> Failure(int lineNumber, string message) =>
Failure(new ParseError(lineNumber, message));
}
@@ -0,0 +1,13 @@
namespace AvParser.Core.Parsing;
/// <summary>Progress snapshot reported while a parse is running.</summary>
/// <param name="Processed">Records handled so far.</param>
/// <param name="Total">Expected total, or <c>0</c> when the size is not known up front.</param>
public readonly record struct ParseProgress(int Processed, int Total)
{
/// <summary>Completion in the range <c>0.0 .. 1.0</c>; <c>0</c> when the total is unknown.</summary>
public double Fraction => Total <= 0 ? 0d : Math.Clamp((double)Processed / Total, 0d, 1d);
/// <summary><see langword="true"/> when the total is unknown and the UI should show a busy indicator.</summary>
public bool IsIndeterminate => Total <= 0;
}
+36
View File
@@ -0,0 +1,36 @@
namespace AvParser.Core.Parsing;
/// <summary>One named field of a <see cref="ParsedRecord"/>.</summary>
/// <param name="Name">Column name, or the positional index rendered as text.</param>
/// <param name="Value">Raw field value, already trimmed of surrounding whitespace.</param>
public readonly record struct ParsedField(string Name, string Value)
{
/// <inheritdoc />
public override string ToString() => $"{Name}={Value}";
}
/// <summary>A single successfully parsed record.</summary>
/// <param name="LineNumber">1-based position of the record in the source input.</param>
/// <param name="Fields">The record's fields, in source order.</param>
public sealed record ParsedRecord(int LineNumber, IReadOnlyList<ParsedField> Fields)
{
/// <summary>Flattened <c>key=value</c> rendering, used by the results list.</summary>
public string Summary => string.Join(" ", Fields);
/// <summary>Looks a field up by name; <see langword="null"/> when absent.</summary>
public string? this[string name]
{
get
{
foreach (var field in Fields)
{
if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase))
{
return field.Value;
}
}
return null;
}
}
}
@@ -0,0 +1,39 @@
namespace AvParser.Core.Parsing;
/// <inheritdoc cref="IParserCatalog" />
public sealed class ParserCatalog : IParserCatalog
{
private readonly Dictionary<string, ITextParser> _byId;
/// <summary>Builds a catalog from every parser the container resolved.</summary>
/// <exception cref="ArgumentException">No parsers were registered, or two share an id.</exception>
public ParserCatalog(IEnumerable<ITextParser> parsers)
{
ArgumentNullException.ThrowIfNull(parsers);
Parsers = parsers.OrderBy(p => p.DisplayName, StringComparer.OrdinalIgnoreCase).ToArray();
if (Parsers.Count == 0)
{
throw new ArgumentException("At least one parser must be registered.", nameof(parsers));
}
_byId = new Dictionary<string, ITextParser>(StringComparer.OrdinalIgnoreCase);
foreach (var parser in Parsers)
{
if (!_byId.TryAdd(parser.Id, parser))
{
throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers));
}
}
}
/// <inheritdoc />
public IReadOnlyList<ITextParser> Parsers { get; }
/// <inheritdoc />
public ITextParser DefaultParser => Parsers[0];
/// <inheritdoc />
public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null;
}
@@ -0,0 +1,138 @@
using System.Runtime.CompilerServices;
namespace AvParser.Core.Parsing.Samples;
/// <summary>
/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header.
/// </summary>
/// <remarks>
/// Intentionally simple — no quoting, no escapes. It exists to exercise the
/// <see cref="IParser{TInput,TOutput}"/> contract end to end, not to replace a CSV library.
/// </remarks>
public sealed class DelimitedTextParser : ITextParser
{
private static readonly char[] Candidates = [',', ';', '\t', '|'];
/// <inheritdoc />
public string Id => "delimited";
/// <inheritdoc />
public string DisplayName => "Delimited text";
/// <inheritdoc />
public string Description =>
"First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |).";
/// <inheritdoc />
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0;
/// <inheritdoc />
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(input);
var lines = TextLines.Split(input);
var total = lines.Length;
string[]? header = null;
char separator = ',';
var processed = 0;
for (var i = 0; i < lines.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var line = lines[i];
var lineNumber = i + 1;
processed++;
if (TextLines.IsSkippable(line))
{
ReportEvery(progress, processed, total);
continue;
}
if (header is null)
{
separator = DetectSeparator(line);
header = SplitTrimmed(line, separator);
ReportEvery(progress, processed, total);
continue;
}
var values = SplitTrimmed(line, separator);
if (values.Length != header.Length)
{
yield return ParseOutcome<ParsedRecord>.Failure(
lineNumber,
$"Expected {header.Length} field(s) but found {values.Length}."
);
}
else
{
var fields = new ParsedField[values.Length];
for (var f = 0; f < values.Length; f++)
{
fields[f] = new ParsedField(header[f], values[f]);
}
yield return ParseOutcome<ParsedRecord>.Success(new ParsedRecord(lineNumber, fields));
}
ReportEvery(progress, processed, total);
if (processed % TextLines.YieldInterval == 0)
{
await Task.Yield();
}
}
if (header is null)
{
yield return ParseOutcome<ParsedRecord>.Failure(1, "Input contains no header row.");
}
progress?.Report(new ParseProgress(total, total));
}
private static void ReportEvery(IProgress<ParseProgress>? progress, int processed, int total)
{
if (progress is not null && processed % TextLines.ProgressInterval == 0)
{
progress.Report(new ParseProgress(processed, total));
}
}
private static char DetectSeparator(string headerLine)
{
var best = Candidates[0];
var bestCount = 0;
foreach (var candidate in Candidates)
{
var count = headerLine.Count(c => c == candidate);
if (count > bestCount)
{
bestCount = count;
best = candidate;
}
}
return best;
}
private static string[] SplitTrimmed(string line, char separator)
{
var parts = line.Split(separator);
for (var i = 0; i < parts.Length; i++)
{
parts[i] = parts[i].Trim();
}
return parts;
}
}
@@ -0,0 +1,82 @@
using System.Runtime.CompilerServices;
namespace AvParser.Core.Parsing.Samples;
/// <summary>
/// Sample parser: <c>key=value</c> / <c>key: value</c> lines, ini/env style.
/// </summary>
/// <remarks>
/// A second sample with a different input shape, so the abstraction is proven against more
/// than one implementation before the real domain arrives.
/// </remarks>
public sealed class KeyValueTextParser : ITextParser
{
private static readonly char[] Separators = ['=', ':'];
/// <inheritdoc />
public string Id => "key-value";
/// <inheritdoc />
public string DisplayName => "Key / value pairs";
/// <inheritdoc />
public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments.";
/// <inheritdoc />
public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0;
/// <inheritdoc />
public async IAsyncEnumerable<ParseOutcome<ParsedRecord>> ParseAsync(
string input,
IProgress<ParseProgress>? progress,
[EnumeratorCancellation] CancellationToken cancellationToken
)
{
ArgumentNullException.ThrowIfNull(input);
var lines = TextLines.Split(input);
var total = lines.Length;
for (var i = 0; i < lines.Length; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var line = lines[i];
var lineNumber = i + 1;
var processed = i + 1;
if (!TextLines.IsSkippable(line))
{
var separatorIndex = line.IndexOfAny(Separators);
if (separatorIndex <= 0)
{
yield return ParseOutcome<ParsedRecord>.Failure(lineNumber, "No '=' or ':' separator found.");
}
else
{
var key = line[..separatorIndex].Trim();
var value = line[(separatorIndex + 1)..].Trim();
yield return key.Length == 0
? ParseOutcome<ParsedRecord>.Failure(lineNumber, "Key is empty.")
: ParseOutcome<ParsedRecord>.Success(
new ParsedRecord(lineNumber, [new ParsedField("Key", key), new ParsedField("Value", value)])
);
}
}
if (progress is not null && processed % TextLines.ProgressInterval == 0)
{
progress.Report(new ParseProgress(processed, total));
}
if (processed % TextLines.YieldInterval == 0)
{
await Task.Yield();
}
}
progress?.Report(new ParseProgress(total, total));
}
}
@@ -0,0 +1,30 @@
namespace AvParser.Core.Parsing.Samples;
/// <summary>Line-splitting helpers shared by the sample parsers.</summary>
internal static class TextLines
{
/// <summary>How many records to process between progress reports.</summary>
internal const int ProgressInterval = 256;
/// <summary>How many records to process between cooperative yields.</summary>
internal const int YieldInterval = 1024;
/// <summary>Splits input into lines, normalising CRLF and stripping a trailing empty line.</summary>
internal static string[] Split(string input)
{
var lines = input.Split('\n');
for (var i = 0; i < lines.Length; i++)
{
lines[i] = lines[i].TrimEnd('\r');
}
// A file that ends with a newline yields a phantom trailing empty line; drop it so
// progress totals and line numbers match what the user sees in their editor.
return lines is [.., ""] ? lines[..^1] : lines;
}
/// <summary>Blank lines and <c>#</c> comments carry no records.</summary>
internal static bool IsSkippable(string line) =>
string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart()[0] == '#';
}
+42
View File
@@ -0,0 +1,42 @@
namespace AvParser.Core.Settings;
/// <summary>Theme preference. <see cref="System"/> follows the OS setting.</summary>
public enum AppTheme
{
/// <summary>Follow the operating system.</summary>
System = 0,
/// <summary>Always light.</summary>
Light = 1,
/// <summary>Always dark.</summary>
Dark = 2,
}
/// <summary>
/// Everything the app remembers between runs. Persisted verbatim as JSON.
/// </summary>
/// <remarks>
/// A mutable record with defaults on every property: a settings file written by an older
/// version must still deserialise, so no property may be required.
/// </remarks>
public sealed record AppSettings
{
/// <summary>Chosen theme variant.</summary>
public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
public string? LastParserId { get; init; }
/// <summary>Last main-window width in device-independent pixels.</summary>
public double WindowWidth { get; init; } = 1280;
/// <summary>Last main-window height in device-independent pixels.</summary>
public double WindowHeight { get; init; } = 800;
/// <summary>Whether the main window was maximised on exit.</summary>
public bool WindowMaximized { get; init; }
/// <summary>Minimum Serilog level, as a Serilog level name.</summary>
public string MinimumLogLevel { get; init; } = "Information";
}
@@ -0,0 +1,22 @@
namespace AvParser.Core.Settings;
/// <summary>Reads and persists <see cref="AppSettings"/>.</summary>
/// <remarks>
/// <see cref="Update"/> is fire-and-forget on purpose: writes are debounced by the
/// implementation so that dragging a window or flicking a toggle does not hit the disk
/// on every change. Call <see cref="FlushAsync"/> on shutdown to force the pending write out.
/// </remarks>
public interface ISettingsService
{
/// <summary>The current in-memory settings. Never <see langword="null"/>.</summary>
AppSettings Current { get; }
/// <summary>Fires after <see cref="Current"/> changes, including the initial load.</summary>
IObservable<AppSettings> Changes { get; }
/// <summary>Applies a change and schedules a debounced save.</summary>
void Update(Func<AppSettings, AppSettings> mutate);
/// <summary>Writes any pending change immediately.</summary>
Task FlushAsync(CancellationToken cancellationToken = default);
}
+12
View File
@@ -0,0 +1,12 @@
<Application
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:semi="https://irihi.tech/semi"
x:Class="AvParser.Desktop.App"
RequestedThemeVariant="Default"
>
<Application.Styles>
<semi:SemiTheme />
<StyleInclude Source="avares://AvParser.UI/Styles/Index.axaml" />
</Application.Styles>
</Application>
+85
View File
@@ -0,0 +1,85 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using AvParser.Core.Settings;
using AvParser.UI;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.DependencyInjection;
namespace AvParser.Desktop;
/// <summary>The Avalonia application. Owns nothing but wiring.</summary>
/// <remarks>
/// The container arrives through the constructor rather than a static field, so the XAML
/// previewer and any test host can construct an <see cref="App"/> that has no container at all
/// and simply skips the composition step.
/// </remarks>
public partial class App : Application
{
private readonly IServiceProvider? _services;
/// <summary>Parameterless constructor used by the XAML previewer.</summary>
public App()
: this(null) { }
/// <summary>Creates the application over a built container.</summary>
/// <param name="services">The container, or <see langword="null"/> for design/preview mode.</param>
public App(IServiceProvider? services) => _services = services;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
/// <inheritdoc />
public override void OnFrameworkInitializationCompleted()
{
if (Design.IsDesignMode || _services is null)
{
base.OnFrameworkInitializationCompleted();
return;
}
DataTemplates.Add(_services.GetRequiredService<ViewLocator>());
// Resolving the theme service applies the persisted variant as a side effect of construction.
_ = _services.GetRequiredService<IThemeService>();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
var settings = _services.GetRequiredService<ISettingsService>();
var window = CreateMainWindow(settings);
desktop.MainWindow = window;
desktop.ShutdownRequested += (_, _) => settings.FlushAsync().GetAwaiter().GetResult();
}
base.OnFrameworkInitializationCompleted();
}
private MainWindow CreateMainWindow(ISettingsService settings)
{
var window = new MainWindow
{
DataContext = _services!.GetRequiredService<ShellViewModel>(),
Width = settings.Current.WindowWidth,
Height = settings.Current.WindowHeight,
WindowState = settings.Current.WindowMaximized ? WindowState.Maximized : WindowState.Normal,
};
window.Closing += (_, _) =>
settings.Update(current =>
current with
{
WindowMaximized = window.WindowState == WindowState.Maximized,
// Persist the restored size, not the maximised one, or un-maximising
// on the next run would leave the window filling the screen.
WindowWidth = window.WindowState == WindowState.Normal ? window.Width : current.WindowWidth,
WindowHeight = window.WindowState == WindowState.Normal ? window.Height : current.WindowHeight,
}
);
return window;
}
}
@@ -0,0 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>AvParser.Desktop</RootNamespace>
<AssemblyName>AvParser</AssemblyName>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<RuntimeIdentifiers>win-x64;linux-x64;osx-x64;osx-arm64</RuntimeIdentifiers>
<!-- The previewer and `dotnet run` both want a console-free window on Windows; on Linux and
macOS WinExe is equivalent to Exe. -->
<ApplicationIcon></ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
<ProjectReference Include="..\AvParser.UI\AvParser.UI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" />
<PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="Semi.Avalonia" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Extensions.Logging" />
</ItemGroup>
<ItemGroup Condition="'$(Configuration)' == 'Debug'">
<!-- F12 opens DevTools, which is the fastest way to see :compact/:medium/:expanded toggle. -->
<PackageReference Include="Avalonia.Diagnostics" />
</ItemGroup>
</Project>
@@ -0,0 +1,22 @@
using Serilog;
namespace AvParser.Desktop.Logging;
/// <summary>
/// Catches exceptions ReactiveUI would otherwise rethrow on the scheduler and kill the process with.
/// </summary>
/// <remarks>
/// Must be installed while ReactiveUI is being configured, i.e. before the first
/// <c>ReactiveCommand</c> is constructed. Installing it later leaves already-built commands on
/// the default handler.
/// </remarks>
internal sealed class SerilogExceptionHandler(ILogger logger) : IObserver<Exception>
{
private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
public void OnNext(Exception value) => _logger.Error(value, "Unhandled ReactiveUI exception");
public void OnError(Exception error) => _logger.Fatal(error, "ReactiveUI exception stream failed");
public void OnCompleted() { }
}
+81
View File
@@ -0,0 +1,81 @@
using Avalonia;
using Avalonia.Controls;
using AvParser.Core.DependencyInjection;
using AvParser.Core.Settings;
using AvParser.Desktop.Logging;
using AvParser.Infrastructure.DependencyInjection;
using AvParser.Infrastructure.Logging;
using AvParser.Infrastructure.Storage;
using AvParser.UI.DependencyInjection;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI.Avalonia;
using Serilog;
namespace AvParser.Desktop;
/// <summary>Composition root and process entry point.</summary>
internal static class Program
{
/// <summary>Builds the container, starts Avalonia, and flushes logs on the way out.</summary>
[STAThread]
public static int Main(string[] args)
{
var paths = new AppPaths();
paths.EnsureCreated();
// The persisted level cannot be read before the container exists, and the container needs
// a logger. Start at Information and narrow it once settings are available.
var (logger, levelSwitch) = AppLogging.Create(paths, "Information");
Log.Logger = logger;
try
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddSerilog(logger, dispose: false));
services.AddSingleton(levelSwitch);
services.AddAvParserCore();
services.AddAvParserInfrastructure(paths);
services.AddAvParserUI();
using var provider = services.BuildServiceProvider(
new ServiceProviderOptions { ValidateOnBuild = true, ValidateScopes = true }
);
var settings = provider.GetRequiredService<ISettingsService>();
levelSwitch.MinimumLevel = AppLogging.ParseLevel(settings.Current.MinimumLogLevel);
Log.Information("AvParser starting; data directory {DataDirectory}", paths.DataDirectory);
return BuildAvaloniaApp(provider).StartWithClassicDesktopLifetime(args, ShutdownMode.OnMainWindowClose);
}
catch (Exception ex)
{
Log.Fatal(ex, "AvParser terminated unexpectedly");
return 1;
}
finally
{
Log.CloseAndFlush();
}
}
/// <summary>
/// Entry point the XAML previewer reflects for.
/// </summary>
/// <remarks>
/// It must stay parameterless and unambiguous: an optional parameter makes the previewer's
/// zero-argument invoke throw, and a second overload of the same name makes its
/// <c>GetMethod("BuildAvaloniaApp")</c> throw <see cref="System.Reflection.AmbiguousMatchException"/>.
/// Hence the distinct name for the real builder below.
/// </remarks>
public static AppBuilder BuildAvaloniaApp() => BuildAvaloniaApp(null);
private static AppBuilder BuildAvaloniaApp(IServiceProvider? services) =>
AppBuilder
.Configure(() => new App(services))
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI(builder => builder.WithExceptionHandler(new SerilogExceptionHandler(Log.Logger)));
}
+20
View File
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="AvParser.Desktop" />
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<!-- Without per-monitor-v2 the shell is bitmap-stretched on a scaled display, which makes
the whole point of a crisp adaptive layout moot. -->
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true</dpiAware>
</windowsSettings>
</application>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 / 11 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>
@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Infrastructure</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AvParser.Core\AvParser.Core.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<!-- Signal<T> / operators. The UI-free half of the ReactiveUI stack — no Avalonia here. -->
<PackageReference Include="ReactiveUI.Primitives" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
</ItemGroup>
</Project>
@@ -0,0 +1,36 @@
using AvParser.Core.Settings;
using AvParser.Infrastructure.Settings;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.DependencyInjection;
/// <summary>Composition root for the infrastructure layer.</summary>
public static class InfrastructureServiceCollectionExtensions
{
/// <summary>Registers filesystem paths and the persisted settings service.</summary>
/// <param name="services">The collection to add to.</param>
/// <param name="paths">
/// Explicit paths, or <see langword="null"/> to use the current user's application-data folder.
/// Tests pass a temp directory here.
/// </param>
public static IServiceCollection AddAvParserInfrastructure(this IServiceCollection services, AppPaths? paths = null)
{
ArgumentNullException.ThrowIfNull(services);
var resolved = paths ?? new AppPaths();
resolved.EnsureCreated();
services.AddSingleton<IAppPaths>(resolved);
// Constructed explicitly rather than by type: the optional ISequencer parameter would
// otherwise make the container's constructor choice depend on registration order.
services.AddSingleton<ISettingsService>(sp => new JsonSettingsService(
sp.GetRequiredService<IAppPaths>(),
sp.GetRequiredService<ILogger<JsonSettingsService>>()
));
return services;
}
}
@@ -0,0 +1,60 @@
using AvParser.Infrastructure.Storage;
using Serilog;
using Serilog.Core;
using Serilog.Events;
namespace AvParser.Infrastructure.Logging;
/// <summary>Builds the application's Serilog pipeline.</summary>
public static class AppLogging
{
private const string OutputTemplate =
"[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}";
/// <summary>
/// Creates a console + rolling-file logger writing into <see cref="IAppPaths.LogDirectory"/>.
/// </summary>
/// <param name="paths">Where log files go.</param>
/// <param name="minimumLevel">Serilog level name; unrecognised values fall back to Information.</param>
/// <remarks>
/// The level is exposed through a <see cref="LoggingLevelSwitch"/> so the Settings page can
/// change it at runtime without rebuilding the pipeline or restarting the app.
/// </remarks>
public static (Logger Logger, LoggingLevelSwitch LevelSwitch) Create(IAppPaths paths, string minimumLevel)
{
ArgumentNullException.ThrowIfNull(paths);
Directory.CreateDirectory(paths.LogDirectory);
var levelSwitch = new LoggingLevelSwitch(ParseLevel(minimumLevel));
var logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
Path.Combine(paths.LogDirectory, "avparser-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
outputTemplate: OutputTemplate
)
.CreateLogger();
return (logger, levelSwitch);
}
/// <summary>Parses a Serilog level name, defaulting to <see cref="LogEventLevel.Information"/>.</summary>
public static LogEventLevel ParseLevel(string? name) =>
Enum.TryParse<LogEventLevel>(name, ignoreCase: true, out var level) ? level : LogEventLevel.Information;
/// <summary>The level names offered in the Settings page, ordered from most to least verbose.</summary>
public static IReadOnlyList<string> AvailableLevels { get; } =
[
nameof(LogEventLevel.Verbose),
nameof(LogEventLevel.Debug),
nameof(LogEventLevel.Information),
nameof(LogEventLevel.Warning),
nameof(LogEventLevel.Error),
nameof(LogEventLevel.Fatal),
];
}
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
using AvParser.Core.Settings;
namespace AvParser.Infrastructure.Settings;
/// <summary>Source-generated serialiser metadata for <see cref="AppSettings"/>.</summary>
/// <remarks>Keeps settings IO reflection-free, which matters if the app is ever trimmed or AOT-published.</remarks>
[JsonSourceGenerationOptions(
WriteIndented = true,
UseStringEnumConverter = true,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase
)]
[JsonSerializable(typeof(AppSettings))]
internal sealed partial class AppSettingsJsonContext : JsonSerializerContext;
@@ -0,0 +1,136 @@
using System.Text.Json;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
using ReactiveUI.Primitives.Extensions;
using ReactiveUI.Primitives.Signals;
namespace AvParser.Infrastructure.Settings;
/// <summary>
/// Persists <see cref="AppSettings"/> to a JSON file, debouncing writes.
/// </summary>
/// <remarks>
/// Window resizes and slider drags produce a burst of updates; writing each one would hammer
/// the disk for no benefit. Updates are coalesced over <see cref="SaveDebounce"/> and the
/// final state is written atomically (temp file + move) so a crash mid-write cannot leave a
/// truncated settings file behind.
/// </remarks>
public sealed class JsonSettingsService : ISettingsService, IDisposable
{
/// <summary>How long to wait for the update burst to settle before writing.</summary>
public static readonly TimeSpan SaveDebounce = TimeSpan.FromSeconds(1);
private readonly IAppPaths _paths;
private readonly ILogger<JsonSettingsService> _logger;
private readonly BehaviorSignal<AppSettings> _current;
private readonly Signal<AppSettings> _saveRequests = new();
private readonly IDisposable _saveSubscription;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly Lock _gate = new();
/// <summary>Loads settings from disk, falling back to defaults on any problem.</summary>
public JsonSettingsService(IAppPaths paths, ILogger<JsonSettingsService> logger, ISequencer? saveScheduler = null)
{
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_current = new BehaviorSignal<AppSettings>(Load());
_saveSubscription = _saveRequests
.Throttle(SaveDebounce, saveScheduler ?? TaskPoolSequencer.Instance)
.Subscribe(settings => _ = SaveAsync(settings, CancellationToken.None));
}
/// <inheritdoc />
public AppSettings Current => _current.Value;
/// <inheritdoc />
public IObservable<AppSettings> Changes => _current;
/// <inheritdoc />
public void Update(Func<AppSettings, AppSettings> mutate)
{
ArgumentNullException.ThrowIfNull(mutate);
AppSettings next;
lock (_gate)
{
var previous = _current.Value;
next = mutate(previous) ?? throw new InvalidOperationException("Mutation returned null settings.");
if (next == previous)
{
return; // records compare by value: a no-op edit must not trigger a write
}
_current.OnNext(next);
}
_saveRequests.OnNext(next);
}
/// <inheritdoc />
public Task FlushAsync(CancellationToken cancellationToken = default) =>
SaveAsync(_current.Value, cancellationToken);
/// <inheritdoc />
public void Dispose()
{
_saveSubscription.Dispose();
_saveRequests.Dispose();
_current.Dispose();
_writeLock.Dispose();
}
private AppSettings Load()
{
try
{
if (!File.Exists(_paths.SettingsFile))
{
return new AppSettings();
}
var json = File.ReadAllText(_paths.SettingsFile);
return JsonSerializer.Deserialize(json, AppSettingsJsonContext.Default.AppSettings) ?? new AppSettings();
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException)
{
// A corrupt or unreadable settings file must never stop the app from starting.
_logger.LogWarning(
ex,
"Could not read settings from {Path}; falling back to defaults",
_paths.SettingsFile
);
return new AppSettings();
}
}
private async Task SaveAsync(AppSettings settings, CancellationToken cancellationToken)
{
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_paths.SettingsFile)!);
var temp = _paths.SettingsFile + ".tmp";
var json = JsonSerializer.Serialize(settings, AppSettingsJsonContext.Default.AppSettings);
await File.WriteAllTextAsync(temp, json, cancellationToken).ConfigureAwait(false);
File.Move(temp, _paths.SettingsFile, overwrite: true);
_logger.LogDebug("Settings written to {Path}", _paths.SettingsFile);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not write settings to {Path}", _paths.SettingsFile);
}
finally
{
_writeLock.Release();
}
}
}
@@ -0,0 +1,66 @@
namespace AvParser.Infrastructure.Storage;
/// <summary>Resolves the per-user directories the app writes to.</summary>
/// <remarks>
/// An interface rather than a static helper so tests can redirect everything into a temp
/// folder instead of scribbling in the developer's real profile.
/// </remarks>
public interface IAppPaths
{
/// <summary>Root of the per-user data directory. Created on demand.</summary>
string DataDirectory { get; }
/// <summary>Full path of the settings file.</summary>
string SettingsFile { get; }
/// <summary>Directory holding rolling log files.</summary>
string LogDirectory { get; }
}
/// <inheritdoc />
/// <remarks>
/// Uses <see cref="Environment.SpecialFolder.ApplicationData"/>, which maps to
/// <c>%APPDATA%</c> on Windows and <c>~/.config</c> on Linux/macOS.
/// </remarks>
public sealed class AppPaths : IAppPaths
{
private const string FolderName = "AvParser";
/// <summary>Creates paths under the current user's application-data directory.</summary>
public AppPaths()
: this(
Path.Combine(
Environment.GetFolderPath(
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolderOption.Create
),
FolderName
)
) { }
/// <summary>Creates paths under an explicit root. Used by tests.</summary>
public AppPaths(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
DataDirectory = dataDirectory;
SettingsFile = Path.Combine(dataDirectory, "settings.json");
LogDirectory = Path.Combine(dataDirectory, "logs");
}
/// <inheritdoc />
public string DataDirectory { get; }
/// <inheritdoc />
public string SettingsFile { get; }
/// <inheritdoc />
public string LogDirectory { get; }
/// <summary>Creates every directory this instance points at.</summary>
public void EnsureCreated()
{
Directory.CreateDirectory(DataDirectory);
Directory.CreateDirectory(LogDirectory);
}
}
+26
View File
@@ -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();
}
}
+14
View File
@@ -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);
}
}
+16
View File
@@ -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);
}
+62
View File
@@ -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);
}
}
}
+151
View File
@@ -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>
+45
View File
@@ -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>
+19
View File
@@ -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>
+109
View File
@@ -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>
+71
View File
@@ -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>
+77
View File
@@ -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; }
}
+50
View File
@@ -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>
+13
View File
@@ -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);
}
+70
View File
@@ -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);
}
+19
View File
@@ -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>
+13
View File
@@ -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);
}
+216
View File
@@ -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>
+13
View File
@@ -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);
}
+89
View File
@@ -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);
}
+115
View File
@@ -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>
+34
View File
@@ -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);
}
@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.Core.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,114 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class DelimitedTextParserTests
{
private readonly DelimitedTextParser _parser = new();
[Fact]
public async Task Parses_header_and_rows()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1,Ada\n2,Grace");
errors.ShouldBeEmpty();
records.Count.ShouldBe(2);
records[0].Field("id").ShouldBe("1");
records[0].Field("name").ShouldBe("Ada");
records[1].LineNumber.ShouldBe(3);
}
[Theory]
[InlineData("a;b\n1;2")]
[InlineData("a\tb\n1\t2")]
[InlineData("a|b\n1|2")]
public async Task Detects_the_delimiter_from_the_header(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Fields.Count.ShouldBe(2);
}
[Fact]
public async Task Reports_a_field_count_mismatch_without_aborting()
{
var (records, errors) = await _parser.CollectAsync("id,name\n1\n2,Grace");
// The bad line becomes an error; the good line after it still parses.
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
records.ShouldHaveSingleItem().Field("name").ShouldBe("Grace");
}
[Fact]
public async Task Skips_blank_lines_and_comments()
{
var (records, errors) = await _parser.CollectAsync("# a comment\nid,name\n\n1,Ada\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Trims_surrounding_whitespace()
{
var (records, _) = await _parser.CollectAsync("id , name\n 1 , Ada ");
records.ShouldHaveSingleItem().Field("name").ShouldBe("Ada");
}
[Fact]
public async Task Reports_an_error_when_there_is_no_header()
{
var (records, errors) = await _parser.CollectAsync("\n\n");
records.ShouldBeEmpty();
errors.ShouldHaveSingleItem().Message.ShouldContain("header");
}
[Fact]
public async Task Reports_progress_reaching_completion()
{
var reports = new List<ParseProgress>();
// Not Progress<T>: it posts to the captured synchronization context, so the reports would
// arrive after the assertions. A direct IProgress<T> keeps the test deterministic.
await foreach (
var _ in _parser.ParseAsync(
ParserTestExtensions.DelimitedDocument(900),
new SynchronousProgress<ParseProgress>(reports.Add),
TestContext.Current.CancellationToken
)
) { }
reports.ShouldNotBeEmpty();
reports[^1].Fraction.ShouldBe(1d);
}
[Fact]
public async Task Honours_cancellation()
{
using var cancellation = new CancellationTokenSource();
var act = async () =>
{
await foreach (
var _ in _parser.ParseAsync(ParserTestExtensions.DelimitedDocument(20_000), null, cancellation.Token)
)
{
await cancellation.CancelAsync();
}
};
await act.ShouldThrowAsync<OperationCanceledException>();
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("no delimiters here", false)]
[InlineData("a,b", true)]
public void CanParse_checks_for_a_delimiter(string input, bool expected) =>
_parser.CanParse(input).ShouldBe(expected);
}
@@ -0,0 +1,64 @@
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class KeyValueTextParserTests
{
private readonly KeyValueTextParser _parser = new();
[Theory]
[InlineData("host = localhost")]
[InlineData("host: localhost")]
public async Task Accepts_both_separators(string input)
{
var (records, errors) = await _parser.CollectAsync(input);
errors.ShouldBeEmpty();
var record = records.ShouldHaveSingleItem();
record.Field("Key").ShouldBe("host");
record.Field("Value").ShouldBe("localhost");
}
[Fact]
public async Task Splits_on_the_first_separator_only()
{
var (records, _) = await _parser.CollectAsync("url = https://example.com:8080/path");
records.ShouldHaveSingleItem().Field("Value").ShouldBe("https://example.com:8080/path");
}
[Fact]
public async Task Reports_lines_without_a_separator()
{
var (records, errors) = await _parser.CollectAsync("host = localhost\ngarbage\nport = 80");
records.Count.ShouldBe(2);
errors.ShouldHaveSingleItem().LineNumber.ShouldBe(2);
}
[Fact]
public async Task Reports_an_empty_key()
{
var (_, errors) = await _parser.CollectAsync("= orphan");
errors.ShouldHaveSingleItem().Message.ShouldContain("separator");
}
[Fact]
public async Task Skips_comments_and_blank_lines()
{
var (records, errors) = await _parser.CollectAsync("# comment\n\nhost = localhost\n");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Key").ShouldBe("host");
}
[Fact]
public async Task Allows_an_empty_value()
{
var (records, errors) = await _parser.CollectAsync("host =");
errors.ShouldBeEmpty();
records.ShouldHaveSingleItem().Field("Value").ShouldBe(string.Empty);
}
}
@@ -0,0 +1,37 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
namespace AvParser.Core.Tests;
public class ParserCatalogTests
{
private static IParserCatalog Catalog() => new ParserCatalog([new KeyValueTextParser(), new DelimitedTextParser()]);
[Fact]
public void Orders_parsers_by_display_name_regardless_of_registration_order() =>
Catalog().Parsers.Select(p => p.Id).ShouldBe(["delimited", "key-value"]);
[Fact]
public void Finds_a_parser_by_id_ignoring_case() => Catalog().Find("KEY-VALUE")!.Id.ShouldBe("key-value");
[Fact]
public void Returns_null_for_an_unknown_id() => Catalog().Find("nope").ShouldBeNull();
[Fact]
public void Falls_back_to_the_default_for_an_unknown_id()
{
var catalog = Catalog();
catalog.FindOrDefault("nope").ShouldBeSameAs(catalog.DefaultParser);
catalog.FindOrDefault(null).ShouldBeSameAs(catalog.DefaultParser);
}
[Fact]
public void Rejects_an_empty_registration() => Should.Throw<ArgumentException>(() => new ParserCatalog([]));
[Fact]
public void Rejects_duplicate_ids() =>
Should
.Throw<ArgumentException>(() => new ParserCatalog([new DelimitedTextParser(), new DelimitedTextParser()]))
.Message.ShouldContain("Duplicate");
}
@@ -0,0 +1,47 @@
using AvParser.Core.Parsing;
namespace AvParser.Core.Tests;
/// <summary>Collection helpers so the tests read as assertions rather than as loops.</summary>
internal static class ParserTestExtensions
{
/// <summary>
/// Drains a parse into memory, using the ambient test cancellation token.
/// </summary>
/// <remarks>
/// Deliberately takes no <see cref="CancellationToken"/>: every call site would otherwise have
/// to pass <c>TestContext.Current.CancellationToken</c> to satisfy xUnit1051. Cancellation
/// behaviour is covered by driving <see cref="IParser{TInput,TOutput}.ParseAsync"/> directly.
/// </remarks>
internal static async Task<(List<ParsedRecord> Records, List<ParseError> Errors)> CollectAsync(
this ITextParser parser,
string input,
IProgress<ParseProgress>? progress = null
)
{
var records = new List<ParsedRecord>();
var errors = new List<ParseError>();
await foreach (var outcome in parser.ParseAsync(input, progress, TestContext.Current.CancellationToken))
{
if (outcome.IsSuccess)
{
records.Add(outcome.Value!);
}
else
{
errors.Add(outcome.Error);
}
}
return (records, errors);
}
/// <summary>Reads a field by name, failing the test if it is absent.</summary>
internal static string Field(this ParsedRecord record, string name) =>
record[name] ?? throw new InvalidOperationException($"Field '{name}' is missing.");
/// <summary>Builds a delimited document with a header plus <paramref name="rows"/> data rows.</summary>
internal static string DelimitedDocument(int rows) =>
string.Join('\n', Enumerable.Range(0, rows + 1).Select(i => i == 0 ? "id,name" : $"{i},row{i}"));
}
@@ -0,0 +1,13 @@
namespace AvParser.Core.Tests;
/// <summary>
/// An <see cref="IProgress{T}"/> that invokes its callback inline.
/// </summary>
/// <remarks>
/// <see cref="Progress{T}"/> marshals through the captured synchronization context, which makes
/// the delivery order untestable. This one reports on the calling thread.
/// </remarks>
internal sealed class SynchronousProgress<T>(Action<T> onReport) : IProgress<T>
{
public void Report(T value) => onReport(value);
}
@@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.UI.HeadlessTests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\..\src\AvParser.UI\AvParser.UI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="ReactiveUI.Avalonia" />
<PackageReference Include="Semi.Avalonia" />
</ItemGroup>
<ItemGroup>
<Using Include="ReactiveUI.Primitives" />
</ItemGroup>
</Project>
+41
View File
@@ -0,0 +1,41 @@
using AvParser.Core.Settings;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
using ReactiveUI.Primitives.Signals;
namespace AvParser.UI.HeadlessTests;
/// <summary>
/// Test doubles for the headless tests.
/// </summary>
/// <remarks>
/// Intentionally duplicated from <c>AvParser.UI.Tests</c> rather than shared through a fourth
/// project: these are a few lines of trivial stand-ins, and a shared test-kit assembly would have
/// to opt out of the <c>tests/</c> conventions (self-executing xUnit exe) to build at all.
/// </remarks>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
{
public override string Title { get; } = title;
public override string IconKey { get; } = iconKey;
}
/// <inheritdoc cref="IThemeService" />
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
{
private readonly BehaviorSignal<AppTheme> _current = new(initial);
public AppTheme Current => _current.Value;
public IObservable<AppTheme> Changes => _current;
public void Apply(AppTheme theme)
{
if (theme != _current.Value)
{
_current.OnNext(theme);
}
}
public void Dispose() => _current.Dispose();
}
@@ -0,0 +1,96 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using AvParser.UI.Responsive;
namespace AvParser.UI.HeadlessTests;
public class ResponsiveTests
{
[Theory]
[InlineData(320, Breakpoint.Compact)]
[InlineData(719, Breakpoint.Compact)]
[InlineData(720, Breakpoint.Medium)]
[InlineData(1000, Breakpoint.Medium)]
[InlineData(1100, Breakpoint.Expanded)]
[InlineData(1920, Breakpoint.Expanded)]
public void Classify_maps_width_to_a_breakpoint(double width, Breakpoint expected) =>
ResponsiveLayout.Classify(width).ShouldBe(expected);
[Fact]
public void Hysteresis_widens_whichever_band_we_are_already_in()
{
// Sitting just under the medium threshold, a nudge upward must not flip the layout...
ResponsiveLayout
.Classify(ResponsiveLayout.MediumMinWidth + 10, Breakpoint.Compact)
.ShouldBe(Breakpoint.Compact);
// ...but a decisive move past the deadband must.
ResponsiveLayout
.Classify(ResponsiveLayout.MediumMinWidth + ResponsiveLayout.Hysteresis, Breakpoint.Compact)
.ShouldBe(Breakpoint.Medium);
// Symmetrically on the way down from expanded.
ResponsiveLayout
.Classify(ResponsiveLayout.ExpandedMinWidth - 10, Breakpoint.Expanded)
.ShouldBe(Breakpoint.Expanded);
ResponsiveLayout
.Classify(ResponsiveLayout.ExpandedMinWidth - ResponsiveLayout.Hysteresis - 1, Breakpoint.Expanded)
.ShouldBe(Breakpoint.Medium);
}
[AvaloniaTheory]
[InlineData(500, Breakpoint.Compact, ":compact")]
[InlineData(900, Breakpoint.Medium, ":medium")]
[InlineData(1400, Breakpoint.Expanded, ":expanded")]
public void Laying_out_a_control_sets_the_breakpoint_and_its_pseudoclass(
double width,
Breakpoint expected,
string pseudoClass
)
{
var host = new Border();
ResponsiveLayout.SetIsEnabled(host, true);
// Measure/Arrange directly rather than resizing a Window: the headless window manager's
// resize path is the flakiest thing available, and this is what actually drives Bounds.
host.Measure(new Size(width, 800));
host.Arrange(new Rect(0, 0, width, 800));
Dispatcher.UIThread.RunJobs();
ResponsiveLayout.GetBreakpoint(host).ShouldBe(expected);
host.Classes.Contains(pseudoClass).ShouldBeTrue();
}
[AvaloniaFact]
public void Only_one_breakpoint_pseudoclass_is_active_at_a_time()
{
var host = new Border();
ResponsiveLayout.SetIsEnabled(host, true);
host.Measure(new Size(400, 600));
host.Arrange(new Rect(0, 0, 400, 600));
Dispatcher.UIThread.RunJobs();
host.Classes.Contains(":compact").ShouldBeTrue();
host.Classes.Contains(":medium").ShouldBeFalse();
host.Classes.Contains(":expanded").ShouldBeFalse();
}
[AvaloniaFact]
public void Disabling_the_behaviour_stops_further_updates()
{
var host = new Border();
ResponsiveLayout.SetIsEnabled(host, true);
host.Measure(new Size(400, 600));
host.Arrange(new Rect(0, 0, 400, 600));
Dispatcher.UIThread.RunJobs();
ResponsiveLayout.SetIsEnabled(host, false);
host.Measure(new Size(1400, 600));
host.Arrange(new Rect(0, 0, 1400, 600));
Dispatcher.UIThread.RunJobs();
ResponsiveLayout.GetBreakpoint(host).ShouldBe(Breakpoint.Compact);
}
}
@@ -0,0 +1,182 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Settings;
using AvParser.UI.Navigation;
using AvParser.UI.Responsive;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.HeadlessTests;
public class ShellViewTests
{
/// <summary>
/// Shows the shell inside a window of the requested width.
/// </summary>
/// <remarks>
/// A real <see cref="Window"/> is required — a detached control never builds its visual tree,
/// so there would be no <see cref="SplitView"/> to assert on. The width is set on the window
/// rather than by calling Measure/Arrange by hand: a manual arrange is undone by the window's
/// own next layout pass, which made these assertions depend on pump ordering.
/// </remarks>
private static (ShellView View, ShellViewModel ViewModel, Window Window) ShowShell(double width)
{
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second", "IconDocument")]);
// ImmediateSequencer, not the real main-thread one: derived properties then settle within
// the same layout pass, so a single RunJobs() is enough for the bindings to catch up.
var viewModel = new ShellViewModel(navigation, new FakeThemeService(), ImmediateSequencer.Instance);
var view = new ShellView { DataContext = viewModel };
var window = new Window
{
Width = width,
Height = 800,
Content = view,
};
window.Show();
Dispatcher.UIThread.RunJobs();
return (view, viewModel, window);
}
private static void Resize(Window window, double width)
{
window.Width = width;
Dispatcher.UIThread.RunJobs();
}
private static SplitView NavPaneOf(Visual view) => view.GetVisualDescendants().OfType<SplitView>().Single();
[AvaloniaTheory]
[InlineData(500, SplitViewDisplayMode.Overlay)]
[InlineData(900, SplitViewDisplayMode.CompactInline)]
[InlineData(1400, SplitViewDisplayMode.Inline)]
public void The_navigation_pane_adapts_to_the_shell_width(double width, SplitViewDisplayMode expected)
{
var (view, _, _) = ShowShell(width);
NavPaneOf(view).DisplayMode.ShouldBe(expected);
}
[AvaloniaTheory]
[InlineData(500, Breakpoint.Compact)]
[InlineData(900, Breakpoint.Medium)]
[InlineData(1400, Breakpoint.Expanded)]
public void The_shell_hands_its_breakpoint_to_the_view_model(double width, Breakpoint expected)
{
var (_, viewModel, _) = ShowShell(width);
viewModel.Breakpoint.ShouldBe(expected);
}
[AvaloniaFact]
public void Narrowing_the_shell_closes_the_pane_and_widening_reopens_it()
{
var (_, viewModel, window) = ShowShell(1400);
viewModel.IsPaneOpen.ShouldBeTrue();
Resize(window, 480);
viewModel.IsPaneOpen.ShouldBeFalse();
Resize(window, 1400);
viewModel.IsPaneOpen.ShouldBeTrue();
}
[AvaloniaFact]
public void The_rail_lists_every_registered_page()
{
var (view, _, _) = ShowShell(1400);
view.GetVisualDescendants().OfType<ListBox>().Single().ItemCount.ShouldBe(2);
}
[AvaloniaFact]
public void Selecting_a_rail_entry_swaps_the_hosted_page()
{
var (view, viewModel, _) = ShowShell(1400);
var second = viewModel.Pages[1];
viewModel.SelectedPage = second;
Dispatcher.UIThread.RunJobs();
var host = view.GetVisualDescendants().OfType<TransitioningContentControl>().Single();
host.Content.ShouldBeSameAs(second);
viewModel.Title.ShouldBe("Second");
}
/// <summary>
/// Guards against the shell stylesheet silently matching nothing.
/// </summary>
/// <remarks>
/// Avalonia type selectors match the exact type, so <c>UserControl.shell</c> does not match
/// <see cref="ShellView"/> (which derives from <c>ReactiveUserControl&lt;T&gt;</c>). That
/// failure is completely silent — the app renders, just with default metrics — so it needs an
/// explicit assertion on a value only the stylesheet can produce.
/// </remarks>
[AvaloniaFact]
public void The_shell_stylesheet_is_actually_applied()
{
var (view, _, _) = ShowShell(1400);
// 248 comes from the NavPaneWidth token; SplitView's own default is 320.
NavPaneOf(view).OpenPaneLength.ShouldBe(248d);
NavPaneOf(view).CompactPaneLength.ShouldBe(56d);
var pageHost = view.GetVisualDescendants().OfType<Border>().Single(b => b.Name == "PageHost");
pageHost.Padding.ShouldBe(new Thickness(24));
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
paneToggle.IsVisible.ShouldBeFalse();
}
[AvaloniaFact]
public void The_hamburger_reappears_once_the_pane_stops_being_inline()
{
var (view, _, _) = ShowShell(520);
var paneToggle = view.GetVisualDescendants().OfType<Button>().Single(b => b.Name == "PaneToggle");
paneToggle.IsVisible.ShouldBeTrue();
}
[AvaloniaFact]
public void Labels_collapse_away_on_the_icon_rail()
{
var (view, _, _) = ShowShell(900);
var labels = view.GetVisualDescendants()
.OfType<TextBlock>()
.Where(t => t.Classes.Contains("navLabel"))
.ToList();
labels.ShouldNotBeEmpty();
labels.ShouldAllBe(label => !label.IsVisible);
}
[AvaloniaFact]
public void The_theme_tokens_actually_change_with_the_variant()
{
var application = Application.Current.ShouldNotBeNull();
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Light, out var light).ShouldBeTrue();
application.TryFindResource("AppSurfaceBrush", ThemeVariant.Dark, out var dark).ShouldBeTrue();
// If this fails, Styles/Index.axaml was not loaded by the test app and every other style
// assertion in this assembly is meaningless.
dark!.ToString().ShouldNotBe(light!.ToString());
}
[AvaloniaFact]
public void Theme_service_maps_the_app_theme_onto_an_avalonia_variant()
{
ThemeService.ToVariant(AppTheme.Light).ShouldBe(ThemeVariant.Light);
ThemeService.ToVariant(AppTheme.Dark).ShouldBe(ThemeVariant.Dark);
ThemeService.ToVariant(AppTheme.System).ShouldBe(ThemeVariant.Default);
}
}
@@ -0,0 +1,94 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Headless.XUnit;
using Avalonia.Media;
using Avalonia.Styling;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace AvParser.UI.HeadlessTests;
public class StyleTests
{
private static T Show<T>(T control, ThemeVariant variant)
where T : Control
{
Application.Current!.RequestedThemeVariant = variant;
var window = new Window
{
Width = 400,
Height = 200,
Content = control,
};
window.Show();
Dispatcher.UIThread.RunJobs();
return control;
}
private static Color TokenColor(string key, ThemeVariant variant)
{
Application.Current!.TryFindResource(key, variant, out var value).ShouldBeTrue();
return value.ShouldBeOfType<SolidColorBrush>().Color;
}
private static Color? RenderedBackground(Control control) =>
(
control.GetVisualDescendants().OfType<ContentPresenter>().FirstOrDefault()?.Background
?? control.GetValue(TemplatedControl.BackgroundProperty)
)
is SolidColorBrush brush
? brush.Color
: null;
[AvaloniaTheory]
[InlineData("Light")]
[InlineData("Dark")]
public void The_primary_button_is_filled_with_the_accent_colour(string variantName)
{
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
var button = Show(new Button { Classes = { "primary" }, Content = "Go" }, variant);
// If this fails the button paints itself white-on-white and simply disappears.
RenderedBackground(button).ShouldBe(TokenColor("AppAccentBrush", variant));
}
[AvaloniaTheory]
[InlineData("Light")]
[InlineData("Dark")]
public void A_card_is_distinguishable_from_the_page_behind_it(string variantName)
{
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
var page = TokenColor("AppSurfaceBrush", variant);
var card = TokenColor("AppSurfaceRaisedBrush", variant);
var border = TokenColor("AppBorderBrush", variant);
// Summed across three channels, 24 is roughly a 3% per-channel step — the point at which
// a card stops being a rectangle you have to squint for. The first light palette here
// scored 33 and was still visually indistinguishable, so the bar is deliberately high.
Distance(page, card).ShouldBeGreaterThan(30);
Distance(page, border).ShouldBeGreaterThan(24);
}
[AvaloniaTheory]
[InlineData("Light")]
[InlineData("Dark")]
public void The_card_style_reaches_a_bare_border(string variantName)
{
var variant = variantName == "Light" ? ThemeVariant.Light : ThemeVariant.Dark;
var card = Show(new Border { Classes = { "card" } }, variant);
card.BorderThickness.ShouldBe(new Thickness(1));
card.Padding.ShouldBe(new Thickness(16));
// The value a DynamicResource actually resolved to for this element's variant — not what
// TryFindResource can dig out when asked for a variant explicitly.
(card.Background as SolidColorBrush)?.Color.ShouldBe(TokenColor("AppSurfaceRaisedBrush", variant));
}
private static int Distance(Color a, Color b) => Math.Abs(a.R - b.R) + Math.Abs(a.G - b.G) + Math.Abs(a.B - b.B);
}
@@ -0,0 +1,44 @@
using Avalonia;
using Avalonia.Headless;
using Avalonia.Markup.Xaml.Styling;
using AvParser.UI.HeadlessTests;
using ReactiveUI.Avalonia;
using Semi.Avalonia;
[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))]
// The headless platform is one dispatcher per assembly; running collections in parallel produces
// intermittent "call from invalid thread" failures rather than useful signal.
[assembly: CollectionBehavior(DisableTestParallelization = true)]
namespace AvParser.UI.HeadlessTests;
/// <summary>Boots a headless Avalonia application for <c>[AvaloniaFact]</c> tests.</summary>
public static class TestAppBuilder
{
/// <summary>Called by Avalonia.Headless.XUnit once per test assembly.</summary>
public static AppBuilder BuildAvaloniaApp() =>
AppBuilder
.Configure<HeadlessTestApp>()
.UseHeadless(new AvaloniaHeadlessPlatformOptions())
.UseReactiveUI(_ => { });
}
/// <summary>
/// A minimal application that loads the real styles but never touches the DI container.
/// </summary>
/// <remarks>
/// Deliberately not <c>AvParser.Desktop.App</c>: these tests should exercise the views and the
/// stylesheet, not Serilog, the settings file or the composition root. Styles are added in code
/// rather than XAML so the test project needs no Avalonia XAML compilation of its own.
/// </remarks>
public sealed class HeadlessTestApp : Application
{
/// <inheritdoc />
public override void Initialize()
{
Styles.Add(new SemiTheme());
var index = new Uri("avares://AvParser.UI/Styles/Index.axaml");
Styles.Add(new StyleInclude(index) { Source = index });
}
}
@@ -0,0 +1,75 @@
using Avalonia.Controls;
using Avalonia.Headless.XUnit;
using AvParser.UI;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.DependencyInjection;
namespace AvParser.UI.HeadlessTests;
public class ViewLocatorTests
{
private static ViewLocator Locator(Action<IServiceCollection>? configure = null)
{
var services = new ServiceCollection();
configure?.Invoke(services);
return new ViewLocator(services.BuildServiceProvider());
}
[Fact]
public void Matches_view_models_only()
{
var locator = Locator();
locator.Match(new FakePage("x")).ShouldBeTrue();
locator.Match("a string").ShouldBeFalse();
locator.Match(null).ShouldBeFalse();
}
[AvaloniaFact]
public void Resolves_a_view_by_naming_convention()
{
var locator = Locator();
var viewModel = new AboutViewModel(new TestPaths());
var view = locator.Build(viewModel);
view.ShouldBeOfType<AboutView>();
view.DataContext.ShouldBeSameAs(viewModel);
}
[AvaloniaFact]
public void Prefers_a_registered_view_over_activation()
{
var registered = new AboutView();
var locator = Locator(services => services.AddSingleton(registered));
var view = locator.Build(new AboutViewModel(new TestPaths()));
view.ShouldBeSameAs(registered);
}
[AvaloniaFact]
public void Reports_a_missing_view_instead_of_throwing()
{
var locator = Locator();
// "FakePage" matches neither half of the convention, so the locator must report a miss
// rather than resolving the view model as its own view and failing to activate it.
var view = locator.Build(new FakePage("x"));
view.ShouldBeOfType<TextBlock>().Text!.ShouldContain("View not found");
}
[Fact]
public void Builds_a_placeholder_for_a_null_view_model() => Locator().Build(null).ShouldBeOfType<TextBlock>();
private sealed class TestPaths : Infrastructure.Storage.IAppPaths
{
public string DataDirectory => Path.Combine(Path.GetTempPath(), "AvParserTests");
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
public string LogDirectory => Path.Combine(DataDirectory, "logs");
}
}
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>AvParser.UI.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AvParser.Core\AvParser.Core.csproj" />
<ProjectReference Include="..\..\src\AvParser.Infrastructure\AvParser.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\AvParser.UI\AvParser.UI.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="ReactiveUI.Testing" />
</ItemGroup>
<ItemGroup>
<!-- Subscribe(Action&lt;T&gt;) and the operator set live here; without it every test file
needs the same using just to call .Subscribe on an IObservable. -->
<Using Include="ReactiveUI.Primitives" />
</ItemGroup>
</Project>
+19
View File
@@ -0,0 +1,19 @@
using AvParser.UI.ViewModels;
namespace AvParser.UI.Tests.Fakes;
/// <summary>A navigation destination with no behaviour, for exercising the shell and the stack.</summary>
internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel
{
public override string Title { get; } = title;
public override string IconKey { get; } = iconKey;
}
/// <summary>A second page type, so <c>NavigateTo&lt;TPage&gt;()</c> has something to discriminate on.</summary>
internal sealed class OtherFakePage : PageViewModel
{
public override string Title => "Other";
public override string IconKey => "IconInfo";
}
@@ -0,0 +1,35 @@
using AvParser.Core.Settings;
using ReactiveUI.Primitives.Signals;
namespace AvParser.UI.Tests.Fakes;
/// <summary>In-memory settings, so tests never touch the developer's real profile.</summary>
internal sealed class FakeSettingsService(AppSettings? initial = null) : ISettingsService, IDisposable
{
private readonly BehaviorSignal<AppSettings> _current = new(initial ?? new AppSettings());
public AppSettings Current => _current.Value;
public IObservable<AppSettings> Changes => _current;
/// <summary>How many times <see cref="FlushAsync"/> was called.</summary>
public int FlushCount { get; private set; }
/// <summary>Every value the settings have taken, oldest first.</summary>
public List<AppSettings> History { get; } = [];
public void Update(Func<AppSettings, AppSettings> mutate)
{
var next = mutate(_current.Value);
History.Add(next);
_current.OnNext(next);
}
public Task FlushAsync(CancellationToken cancellationToken = default)
{
FlushCount++;
return Task.CompletedTask;
}
public void Dispose() => _current.Dispose();
}
@@ -0,0 +1,27 @@
using AvParser.Core.Settings;
using AvParser.UI.Services;
using ReactiveUI.Primitives.Signals;
namespace AvParser.UI.Tests.Fakes;
/// <summary>Theme service with no Avalonia <c>Application</c> behind it.</summary>
internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable
{
private readonly BehaviorSignal<AppTheme> _current = new(initial);
public AppTheme Current => _current.Value;
public IObservable<AppTheme> Changes => _current;
public void Apply(AppTheme theme)
{
if (theme == _current.Value)
{
return;
}
_current.OnNext(theme);
}
public void Dispose() => _current.Dispose();
}
@@ -0,0 +1,111 @@
using AvParser.UI.Navigation;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
namespace AvParser.UI.Tests;
public class NavigationServiceTests
{
private static (NavigationService Service, FakePage First, FakePage Second, OtherFakePage Third) Build()
{
var first = new FakePage("First");
var second = new FakePage("Second");
var third = new OtherFakePage();
return (new NavigationService([first, second, third]), first, second, third);
}
[Fact]
public void Starts_on_the_first_registered_page()
{
var (service, first, _, _) = Build();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void Rejects_an_empty_page_set() =>
Should.Throw<ArgumentException>(() => new NavigationService(Array.Empty<PageViewModel>()));
[Fact]
public void Navigating_pushes_the_previous_page()
{
var (service, first, second, _) = Build();
service.NavigateTo(second);
service.Current.ShouldBeSameAs(second);
service.GoBack();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void Navigating_to_the_current_page_is_a_no_op()
{
var (service, first, _, _) = Build();
service.NavigateTo(first);
service.GoBack();
// Nothing was pushed, so GoBack had nothing to pop.
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void GoBack_on_an_empty_stack_does_nothing()
{
var (service, first, _, _) = Build();
service.GoBack();
service.Current.ShouldBeSameAs(first);
}
[Fact]
public void CanGoBack_tracks_the_stack()
{
var (service, _, second, _) = Build();
var observed = new List<bool>();
using var subscription = service.CanGoBack.Subscribe(observed.Add);
service.NavigateTo(second);
service.GoBack();
observed.ShouldBe([false, true, false]);
}
[Fact]
public void Navigates_by_type()
{
var (service, _, _, third) = Build();
service.NavigateTo<OtherFakePage>();
service.Current.ShouldBeSameAs(third);
}
[Fact]
public void Throws_when_navigating_to_an_unregistered_type()
{
var service = new NavigationService([new FakePage("Only")]);
Should
.Throw<InvalidOperationException>(service.NavigateTo<OtherFakePage>)
.Message.ShouldContain("OtherFakePage");
}
[Fact]
public void CurrentChanges_replays_the_present_value()
{
var (service, first, second, _) = Build();
service.NavigateTo(second);
PageViewModel? seen = null;
using var subscription = service.CurrentChanges.Subscribe(page =>
{
seen ??= page;
});
seen.ShouldBeSameAs(second);
seen.ShouldNotBeSameAs(first);
}
}
@@ -0,0 +1,154 @@
using AvParser.Core.Parsing;
using AvParser.Core.Parsing.Samples;
using AvParser.Core.Settings;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ParseViewModelTests
{
private static (ParseViewModel Page, FakeSettingsService Settings) Build(AppSettings? settings = null)
{
var catalog = new ParserCatalog([new DelimitedTextParser(), new KeyValueTextParser()]);
var settingsService = new FakeSettingsService(settings);
return (
new ParseViewModel(
catalog,
settingsService,
NullLogger<ParseViewModel>.Instance,
ImmediateSequencer.Instance
),
settingsService
);
}
private static Task RunAsync(ParseViewModel page) => page.ParseCommand.Execute().ToTask();
[Fact]
public void Restores_the_last_used_parser()
{
var (page, _) = Build(new AppSettings { LastParserId = "key-value" });
page.SelectedParser.Id.ShouldBe("key-value");
}
[Fact]
public void Falls_back_to_the_default_parser_for_an_unknown_id()
{
var (page, _) = Build(new AppSettings { LastParserId = "removed-in-a-past-version" });
page.SelectedParser.Id.ShouldBe("delimited");
}
[Fact]
public void Remembers_the_selected_parser()
{
var (page, settings) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
settings.Current.LastParserId.ShouldBe("key-value");
}
[Theory]
[InlineData("", false)]
[InlineData(" ", false)]
[InlineData("id,name\n1,Ada", true)]
public void Parsing_requires_non_blank_input(string input, bool expected)
{
var (page, _) = Build();
var canExecute = true;
using var subscription = page.ParseCommand.CanExecute.Subscribe(value => canExecute = value);
page.InputText = input;
canExecute.ShouldBe(expected);
}
[Fact]
public async Task Parsing_fills_the_records_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(2);
page.Errors.ShouldBeEmpty();
page.Progress.ShouldBe(1d);
page.StatusMessage!.ShouldContain("2 records");
}
[Fact]
public async Task Bad_lines_land_in_the_errors_collection()
{
var (page, _) = Build();
page.InputText = "id,name\n1\n2,Grace";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
page.Errors.Count.ShouldBe(1);
page.StatusMessage!.ShouldContain("1 error");
}
[Fact]
public async Task A_second_run_replaces_the_previous_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada\n2,Grace";
await RunAsync(page);
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.Records.Count.ShouldBe(1);
}
[Fact]
public async Task Cancelling_stops_the_run_and_says_so()
{
var (page, _) = Build();
page.InputText = string.Join(
'\n',
Enumerable.Range(0, 200_000).Select(i => i == 0 ? "id,name" : $"{i},row{i}")
);
var run = RunAsync(page);
page.CancelCommand.Execute().Subscribe(_ => { });
await run;
page.StatusMessage!.ShouldStartWith("Cancelled");
}
[Fact]
public void Loading_the_sample_matches_the_selected_parser()
{
var (page, _) = Build();
page.SelectedParser = page.Parsers.Single(p => p.Id == "key-value");
page.LoadSampleCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldContain("host = localhost");
}
[Fact]
public async Task Clearing_empties_the_input_and_the_results()
{
var (page, _) = Build();
page.InputText = "id,name\n1,Ada";
await RunAsync(page);
page.ClearCommand.Execute().Subscribe(_ => { });
page.InputText.ShouldBeEmpty();
page.Records.ShouldBeEmpty();
page.StatusMessage.ShouldBeNull();
page.Progress.ShouldBe(0d);
}
}
@@ -0,0 +1,26 @@
using System.Runtime.CompilerServices;
using ReactiveUI.Builder;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
/// <summary>Initialises ReactiveUI once for the whole test assembly.</summary>
/// <remarks>
/// ReactiveUI 24 no longer self-initialises: <c>WhenAnyValue</c> throws
/// <see cref="InvalidOperationException"/> until the builder has run. In the app that happens
/// inside <c>AppBuilder.UseReactiveUI()</c>; here there is no Avalonia app, so a module
/// initialiser does it before any test constructs a view model.
/// </remarks>
internal static class ReactiveUiBootstrap
{
[ModuleInitializer]
internal static void Initialize() =>
RxAppBuilder
.CreateReactiveUIBuilder()
// No dispatcher exists in these tests, so anything ReactiveUI marshals internally
// must run inline rather than being queued onto a thread that never pumps.
.WithMainThreadScheduler(ImmediateSequencer.Instance)
.WithTaskPoolScheduler(ImmediateSequencer.Instance)
.WithCoreServices()
.BuildApp();
}
@@ -0,0 +1,163 @@
using Avalonia.Controls;
using AvParser.Core.Settings;
using AvParser.UI.Navigation;
using AvParser.UI.Responsive;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using ReactiveUI.Primitives;
using ReactiveUI.Primitives.Concurrency;
namespace AvParser.UI.Tests;
public class ShellViewModelTests
{
private static (ShellViewModel Shell, NavigationService Navigation, FakeThemeService Theme) Build(
AppTheme theme = AppTheme.System
)
{
var navigation = new NavigationService([new FakePage("First"), new FakePage("Second"), new OtherFakePage()]);
var themeService = new FakeThemeService(theme);
// ImmediateSequencer makes every derived property settle before the next line runs,
// which is what lets these read as plain synchronous assertions.
return (new ShellViewModel(navigation, themeService, ImmediateSequencer.Instance), navigation, themeService);
}
[Theory]
[InlineData(Breakpoint.Expanded, SplitViewDisplayMode.Inline)]
[InlineData(Breakpoint.Medium, SplitViewDisplayMode.CompactInline)]
[InlineData(Breakpoint.Compact, SplitViewDisplayMode.Overlay)]
public void Breakpoint_selects_the_pane_display_mode(Breakpoint breakpoint, SplitViewDisplayMode expected)
{
var (shell, _, _) = Build();
shell.Breakpoint = breakpoint;
shell.PaneDisplayMode.ShouldBe(expected);
}
[Theory]
[InlineData(Breakpoint.Expanded, true)]
[InlineData(Breakpoint.Medium, false)]
[InlineData(Breakpoint.Compact, false)]
public void Crossing_a_breakpoint_resets_the_pane(Breakpoint breakpoint, bool expectedOpen)
{
var (shell, _, _) = Build();
shell.Breakpoint = breakpoint;
shell.IsPaneOpen.ShouldBe(expectedOpen);
}
[Fact]
public void A_manual_toggle_survives_until_the_next_breakpoint_change()
{
var (shell, _, _) = Build();
shell.Breakpoint = Breakpoint.Compact;
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
shell.IsPaneOpen.ShouldBeTrue();
shell.Breakpoint = Breakpoint.Expanded;
shell.IsPaneOpen.ShouldBeTrue();
shell.Breakpoint = Breakpoint.Compact;
shell.IsPaneOpen.ShouldBeFalse();
}
[Fact]
public void Choosing_a_destination_dismisses_the_compact_drawer()
{
var (shell, _, _) = Build();
shell.Breakpoint = Breakpoint.Compact;
shell.TogglePaneCommand.Execute().Subscribe(_ => { });
shell.IsPaneOpen.ShouldBeTrue();
shell.SelectedPage = shell.Pages[1];
shell.IsPaneOpen.ShouldBeFalse();
}
[Fact]
public void Choosing_a_destination_leaves_the_expanded_sidebar_open()
{
var (shell, _, _) = Build();
shell.Breakpoint = Breakpoint.Expanded;
shell.SelectedPage = shell.Pages[1];
shell.IsPaneOpen.ShouldBeTrue();
}
[Fact]
public void Selecting_a_page_navigates_to_it()
{
var (shell, navigation, _) = Build();
shell.SelectedPage = shell.Pages[2];
navigation.Current.ShouldBeSameAs(shell.Pages[2]);
shell.CurrentPage.ShouldBeSameAs(shell.Pages[2]);
shell.Title.ShouldBe("Other");
}
[Fact]
public void Navigating_from_elsewhere_updates_the_rail_selection()
{
var (shell, navigation, _) = Build();
navigation.NavigateTo<OtherFakePage>();
shell.SelectedPage.ShouldBeSameAs(navigation.Current);
}
[Fact]
public void Back_is_disabled_until_something_is_on_the_stack()
{
var (shell, _, _) = Build();
shell.CanGoBack.ShouldBeFalse();
shell.SelectedPage = shell.Pages[1];
shell.CanGoBack.ShouldBeTrue();
}
[Fact]
public void Back_returns_to_the_previous_page()
{
var (shell, _, _) = Build();
var first = shell.Pages[0];
shell.SelectedPage = shell.Pages[1];
shell.GoBackCommand.Execute().Subscribe(_ => { });
shell.CurrentPage.ShouldBeSameAs(first);
shell.SelectedPage.ShouldBeSameAs(first);
}
[Theory]
[InlineData(AppTheme.Dark, AppTheme.Light)]
[InlineData(AppTheme.Light, AppTheme.Dark)]
[InlineData(AppTheme.System, AppTheme.Dark)]
public void Toggling_the_theme_flips_between_light_and_dark(AppTheme start, AppTheme expected)
{
var (shell, _, theme) = Build(start);
shell.ToggleThemeCommand.Execute().Subscribe(_ => { });
theme.Current.ShouldBe(expected);
}
[Fact]
public void The_theme_button_shows_the_theme_it_would_switch_to()
{
var (shell, _, theme) = Build(AppTheme.Light);
shell.ThemeIconKey.ShouldBe("IconMoon");
theme.Apply(AppTheme.Dark);
shell.ThemeIconKey.ShouldBe("IconSun");
}
}
+31
View File
@@ -0,0 +1,31 @@
<Project>
<!-- MSBuild stops at the nearest Directory.Build.props, so the repo-root one must be
imported explicitly or the test projects would lose the shared settings entirely. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- xUnit v3 test projects are self-executing. -->
<OutputType>Exe</OutputType>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- CA1707: Test_names_read_better_with_underscores.
CA2007: ConfigureAwait is noise in tests.
CA1861: inline arrays as test data are the point.
CA1859: tests deliberately hold interface types to exercise default interface members. -->
<NoWarn>$(NoWarn);CA1707;CA2007;CA1861;CA1859</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="Shouldly" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
</Project>