commit 3db9d4dfc6697d73e48bae4112faf19789478eae Author: Leonid Pershin Date: Thu Aug 13 16:07:08 2026 +0300 Scaffold AvParser: Avalonia 12 shell with adaptive layout Greenfield skeleton for a parser desktop app. The domain is deliberately a placeholder — IParser 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) 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 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..6e2e244 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "csharpier": { + "version": "1.3.0", + "commands": [ + "csharpier" + ], + "rollForward": false + } + } +} \ No newline at end of file diff --git a/.csharpierrc.json b/.csharpierrc.json new file mode 100644 index 0000000..a542eee --- /dev/null +++ b/.csharpierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 120, + "useTabs": false, + "tabWidth": 4, + "endOfLine": "crlf" +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d501a38 --- /dev/null +++ b/.editorconfig @@ -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.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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c262fd4 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bc78471 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/AvParser.slnx b/AvParser.slnx new file mode 100644 index 0000000..9361aed --- /dev/null +++ b/AvParser.slnx @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0114254 --- /dev/null +++ b/CLAUDE.md @@ -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` / `BehaviorSubject` | `Signal` / `BehaviorSignal` | +| `RxApp.MainThreadScheduler` | `RxSchedulers.MainThreadScheduler` | +| `TestScheduler` | `VirtualClock`, `ImmediateSequencer.Instance` | + +Привычные имена операторов (`Select`, `Where`, `Throttle`, `DistinctUntilChanged`, +`CombineLatest`) **работают** — Primitives отдаёт оба набора. `using ReactiveUI.Primitives;` +нужен ради `Subscribe(Action)`. + +**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`) и молча не делает ничего. Использовать + `: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` + `DelimitedTextParser` + `KeyValueTextParser` +существуют, чтобы каркас проверялся end-to-end. Когда появится настоящая доменная логика, +демо-парсеры удаляются вместе с их тестами и `SampleFor`/`LargeSampleFor` в `ParseViewModel`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..cb97bb0 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,46 @@ + + + net10.0 + latest + enable + enable + + false + + + + true + + NU1901;NU1902;NU1903;NU1904 + true + latest-Recommended + true + true + $(NoWarn);CS1591 + + + + true + true + true + false + true + all + + + + AvParser + mrleo1nid + mrleo1nid + 0.1.0 + https://gitea.hsrv.site/mrleo1nid/av-parser + + + + + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..5fb1189 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,63 @@ + + + true + true + + + + + 12.1.1 + 24.1.0 + 7.1.1 + 10.0.11 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/NuGet.config b/NuGet.config new file mode 100644 index 0000000..2e06dd8 --- /dev/null +++ b/NuGet.config @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e4472ab --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# AvParser + +Каркас desktop-приложения на **Avalonia 12** с ReactiveUI-MVVM, адаптивным layout поверх +Semi.Avalonia, единым DI-контейнером и тремя уровнями тестов. + +Доменная часть пока намеренно абстрактная: ядро — это pluggable-контракт +`IParser` и два демо-парсера, чтобы каркас был запускаемым и проверяемым +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` — обычная +форма молча не сматчилась бы ни с чем. На это есть тест +(`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). diff --git a/global.json b/global.json new file mode 100644 index 0000000..d46d21e --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature", + "allowPrerelease": false + } +} diff --git a/src/AvParser.Core/AvParser.Core.csproj b/src/AvParser.Core/AvParser.Core.csproj new file mode 100644 index 0000000..e756b87 --- /dev/null +++ b/src/AvParser.Core/AvParser.Core.csproj @@ -0,0 +1,11 @@ + + + AvParser.Core + + + + + + + diff --git a/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs b/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs new file mode 100644 index 0000000..fb29a29 --- /dev/null +++ b/src/AvParser.Core/DependencyInjection/CoreServiceCollectionExtensions.cs @@ -0,0 +1,27 @@ +using AvParser.Core.Parsing; +using AvParser.Core.Parsing.Samples; +using Microsoft.Extensions.DependencyInjection; + +namespace AvParser.Core.DependencyInjection; + +/// Composition root for the domain layer. +public static class CoreServiceCollectionExtensions +{ + /// + /// Registers every parser plus the catalog that indexes them. + /// + /// + /// Adding a parser is a one-line change here — that is the whole point of the + /// / split. + /// + public static IServiceCollection AddAvParserCore(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } +} diff --git a/src/AvParser.Core/Parsing/IParser.cs b/src/AvParser.Core/Parsing/IParser.cs new file mode 100644 index 0000000..3eba734 --- /dev/null +++ b/src/AvParser.Core/Parsing/IParser.cs @@ -0,0 +1,30 @@ +namespace AvParser.Core.Parsing; + +/// +/// The pluggable unit of the whole application: turns one input into a stream of outcomes. +/// +/// +/// 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. +/// +public interface IParser +{ + /// Stable identifier used for persistence and lookup. Never localise this. + string Id { get; } + + /// Human-readable name shown in the UI. + string DisplayName { get; } + + /// One-line explanation of what this parser accepts. + string Description { get; } + + /// Cheap structural check — must not throw and must not do IO. + bool CanParse(TInput input); + + /// Streams one outcome per logical record. + IAsyncEnumerable> ParseAsync( + TInput input, + IProgress? progress, + CancellationToken cancellationToken + ); +} diff --git a/src/AvParser.Core/Parsing/IParserCatalog.cs b/src/AvParser.Core/Parsing/IParserCatalog.cs new file mode 100644 index 0000000..28aee4e --- /dev/null +++ b/src/AvParser.Core/Parsing/IParserCatalog.cs @@ -0,0 +1,17 @@ +namespace AvParser.Core.Parsing; + +/// Read-only view over every registered text parser. +public interface IParserCatalog +{ + /// All registered parsers, ordered by . + IReadOnlyList Parsers { get; } + + /// The parser used when nothing has been chosen yet. + ITextParser DefaultParser { get; } + + /// Finds a parser by its stable id; when unknown. + ITextParser? Find(string? id); + + /// Finds a parser by id, falling back to . + ITextParser FindOrDefault(string? id) => Find(id) ?? DefaultParser; +} diff --git a/src/AvParser.Core/Parsing/ITextParser.cs b/src/AvParser.Core/Parsing/ITextParser.cs new file mode 100644 index 0000000..2b71073 --- /dev/null +++ b/src/AvParser.Core/Parsing/ITextParser.cs @@ -0,0 +1,10 @@ +namespace AvParser.Core.Parsing; + +/// +/// Closed, non-generic facade over . +/// +/// +/// Open generic interfaces cannot be resolved as IEnumerable<T> by the DI container, +/// so every text-shaped parser implements this closed interface and gets registered under it. +/// +public interface ITextParser : IParser; diff --git a/src/AvParser.Core/Parsing/ParseError.cs b/src/AvParser.Core/Parsing/ParseError.cs new file mode 100644 index 0000000..0fe2042 --- /dev/null +++ b/src/AvParser.Core/Parsing/ParseError.cs @@ -0,0 +1,10 @@ +namespace AvParser.Core.Parsing; + +/// A recoverable problem with a single record. Parsing continues after one of these. +/// 1-based position of the offending record in the input. +/// What went wrong, phrased for a user rather than a developer. +public sealed record ParseError(int LineNumber, string Message) +{ + /// + public override string ToString() => $"Line {LineNumber}: {Message}"; +} diff --git a/src/AvParser.Core/Parsing/ParseOutcome.cs b/src/AvParser.Core/Parsing/ParseOutcome.cs new file mode 100644 index 0000000..f930c54 --- /dev/null +++ b/src/AvParser.Core/Parsing/ParseOutcome.cs @@ -0,0 +1,40 @@ +using System.Diagnostics.CodeAnalysis; + +namespace AvParser.Core.Parsing; + +/// +/// Result of parsing a single record: either a value or a recoverable . +/// +/// +/// 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. +/// +public readonly record struct ParseOutcome +{ + private ParseOutcome(T? value, ParseError? error) + { + Value = value; + Error = error; + } + + /// The parsed value, or when is false. + public T? Value { get; } + + /// The failure, or when is true. + public ParseError? Error { get; } + + /// when a value was produced. + [MemberNotNullWhen(false, nameof(Error))] + public bool IsSuccess => Error is null; + + /// Creates a successful outcome. + public static ParseOutcome Success(T value) => new(value, null); + + /// Creates a failed outcome. + public static ParseOutcome Failure(ParseError error) => + new(default, error ?? throw new ArgumentNullException(nameof(error))); + + /// Creates a failed outcome from its parts. + public static ParseOutcome Failure(int lineNumber, string message) => + Failure(new ParseError(lineNumber, message)); +} diff --git a/src/AvParser.Core/Parsing/ParseProgress.cs b/src/AvParser.Core/Parsing/ParseProgress.cs new file mode 100644 index 0000000..0f46da5 --- /dev/null +++ b/src/AvParser.Core/Parsing/ParseProgress.cs @@ -0,0 +1,13 @@ +namespace AvParser.Core.Parsing; + +/// Progress snapshot reported while a parse is running. +/// Records handled so far. +/// Expected total, or 0 when the size is not known up front. +public readonly record struct ParseProgress(int Processed, int Total) +{ + /// Completion in the range 0.0 .. 1.0; 0 when the total is unknown. + public double Fraction => Total <= 0 ? 0d : Math.Clamp((double)Processed / Total, 0d, 1d); + + /// when the total is unknown and the UI should show a busy indicator. + public bool IsIndeterminate => Total <= 0; +} diff --git a/src/AvParser.Core/Parsing/ParsedRecord.cs b/src/AvParser.Core/Parsing/ParsedRecord.cs new file mode 100644 index 0000000..adb5552 --- /dev/null +++ b/src/AvParser.Core/Parsing/ParsedRecord.cs @@ -0,0 +1,36 @@ +namespace AvParser.Core.Parsing; + +/// One named field of a . +/// Column name, or the positional index rendered as text. +/// Raw field value, already trimmed of surrounding whitespace. +public readonly record struct ParsedField(string Name, string Value) +{ + /// + public override string ToString() => $"{Name}={Value}"; +} + +/// A single successfully parsed record. +/// 1-based position of the record in the source input. +/// The record's fields, in source order. +public sealed record ParsedRecord(int LineNumber, IReadOnlyList Fields) +{ + /// Flattened key=value rendering, used by the results list. + public string Summary => string.Join(" ", Fields); + + /// Looks a field up by name; when absent. + public string? this[string name] + { + get + { + foreach (var field in Fields) + { + if (string.Equals(field.Name, name, StringComparison.OrdinalIgnoreCase)) + { + return field.Value; + } + } + + return null; + } + } +} diff --git a/src/AvParser.Core/Parsing/ParserCatalog.cs b/src/AvParser.Core/Parsing/ParserCatalog.cs new file mode 100644 index 0000000..c311db2 --- /dev/null +++ b/src/AvParser.Core/Parsing/ParserCatalog.cs @@ -0,0 +1,39 @@ +namespace AvParser.Core.Parsing; + +/// +public sealed class ParserCatalog : IParserCatalog +{ + private readonly Dictionary _byId; + + /// Builds a catalog from every parser the container resolved. + /// No parsers were registered, or two share an id. + public ParserCatalog(IEnumerable 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(StringComparer.OrdinalIgnoreCase); + foreach (var parser in Parsers) + { + if (!_byId.TryAdd(parser.Id, parser)) + { + throw new ArgumentException($"Duplicate parser id '{parser.Id}'.", nameof(parsers)); + } + } + } + + /// + public IReadOnlyList Parsers { get; } + + /// + public ITextParser DefaultParser => Parsers[0]; + + /// + public ITextParser? Find(string? id) => id is not null && _byId.TryGetValue(id, out var parser) ? parser : null; +} diff --git a/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs b/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs new file mode 100644 index 0000000..df647bd --- /dev/null +++ b/src/AvParser.Core/Parsing/Samples/DelimitedTextParser.cs @@ -0,0 +1,138 @@ +using System.Runtime.CompilerServices; + +namespace AvParser.Core.Parsing.Samples; + +/// +/// Sample parser: header row plus delimited data rows. Auto-detects the delimiter from the header. +/// +/// +/// Intentionally simple — no quoting, no escapes. It exists to exercise the +/// contract end to end, not to replace a CSV library. +/// +public sealed class DelimitedTextParser : ITextParser +{ + private static readonly char[] Candidates = [',', ';', '\t', '|']; + + /// + public string Id => "delimited"; + + /// + public string DisplayName => "Delimited text"; + + /// + public string Description => + "First non-empty line is the header. Rows are split on the delimiter that dominates it (, ; tab |)."; + + /// + public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Candidates) >= 0; + + /// + public async IAsyncEnumerable> ParseAsync( + string input, + IProgress? 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.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.Success(new ParsedRecord(lineNumber, fields)); + } + + ReportEvery(progress, processed, total); + + if (processed % TextLines.YieldInterval == 0) + { + await Task.Yield(); + } + } + + if (header is null) + { + yield return ParseOutcome.Failure(1, "Input contains no header row."); + } + + progress?.Report(new ParseProgress(total, total)); + } + + private static void ReportEvery(IProgress? 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; + } +} diff --git a/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs b/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs new file mode 100644 index 0000000..6719c9f --- /dev/null +++ b/src/AvParser.Core/Parsing/Samples/KeyValueTextParser.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; + +namespace AvParser.Core.Parsing.Samples; + +/// +/// Sample parser: key=value / key: value lines, ini/env style. +/// +/// +/// A second sample with a different input shape, so the abstraction is proven against more +/// than one implementation before the real domain arrives. +/// +public sealed class KeyValueTextParser : ITextParser +{ + private static readonly char[] Separators = ['=', ':']; + + /// + public string Id => "key-value"; + + /// + public string DisplayName => "Key / value pairs"; + + /// + public string Description => "One pair per line, separated by '=' or ':'. Lines starting with '#' are comments."; + + /// + public bool CanParse(string input) => !string.IsNullOrWhiteSpace(input) && input.IndexOfAny(Separators) >= 0; + + /// + public async IAsyncEnumerable> ParseAsync( + string input, + IProgress? 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.Failure(lineNumber, "No '=' or ':' separator found."); + } + else + { + var key = line[..separatorIndex].Trim(); + var value = line[(separatorIndex + 1)..].Trim(); + + yield return key.Length == 0 + ? ParseOutcome.Failure(lineNumber, "Key is empty.") + : ParseOutcome.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)); + } +} diff --git a/src/AvParser.Core/Parsing/Samples/TextLines.cs b/src/AvParser.Core/Parsing/Samples/TextLines.cs new file mode 100644 index 0000000..6b30fca --- /dev/null +++ b/src/AvParser.Core/Parsing/Samples/TextLines.cs @@ -0,0 +1,30 @@ +namespace AvParser.Core.Parsing.Samples; + +/// Line-splitting helpers shared by the sample parsers. +internal static class TextLines +{ + /// How many records to process between progress reports. + internal const int ProgressInterval = 256; + + /// How many records to process between cooperative yields. + internal const int YieldInterval = 1024; + + /// Splits input into lines, normalising CRLF and stripping a trailing empty line. + 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; + } + + /// Blank lines and # comments carry no records. + internal static bool IsSkippable(string line) => + string.IsNullOrWhiteSpace(line) || line.AsSpan().TrimStart()[0] == '#'; +} diff --git a/src/AvParser.Core/Settings/AppSettings.cs b/src/AvParser.Core/Settings/AppSettings.cs new file mode 100644 index 0000000..1be5300 --- /dev/null +++ b/src/AvParser.Core/Settings/AppSettings.cs @@ -0,0 +1,42 @@ +namespace AvParser.Core.Settings; + +/// Theme preference. follows the OS setting. +public enum AppTheme +{ + /// Follow the operating system. + System = 0, + + /// Always light. + Light = 1, + + /// Always dark. + Dark = 2, +} + +/// +/// Everything the app remembers between runs. Persisted verbatim as JSON. +/// +/// +/// 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. +/// +public sealed record AppSettings +{ + /// Chosen theme variant. + public AppTheme Theme { get; init; } = AppTheme.System; + + /// Id of the parser selected last time; resolved leniently on load. + public string? LastParserId { get; init; } + + /// Last main-window width in device-independent pixels. + public double WindowWidth { get; init; } = 1280; + + /// Last main-window height in device-independent pixels. + public double WindowHeight { get; init; } = 800; + + /// Whether the main window was maximised on exit. + public bool WindowMaximized { get; init; } + + /// Minimum Serilog level, as a Serilog level name. + public string MinimumLogLevel { get; init; } = "Information"; +} diff --git a/src/AvParser.Core/Settings/ISettingsService.cs b/src/AvParser.Core/Settings/ISettingsService.cs new file mode 100644 index 0000000..da0d6d4 --- /dev/null +++ b/src/AvParser.Core/Settings/ISettingsService.cs @@ -0,0 +1,22 @@ +namespace AvParser.Core.Settings; + +/// Reads and persists . +/// +/// 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 on shutdown to force the pending write out. +/// +public interface ISettingsService +{ + /// The current in-memory settings. Never . + AppSettings Current { get; } + + /// Fires after changes, including the initial load. + IObservable Changes { get; } + + /// Applies a change and schedules a debounced save. + void Update(Func mutate); + + /// Writes any pending change immediately. + Task FlushAsync(CancellationToken cancellationToken = default); +} diff --git a/src/AvParser.Desktop/App.axaml b/src/AvParser.Desktop/App.axaml new file mode 100644 index 0000000..0f06777 --- /dev/null +++ b/src/AvParser.Desktop/App.axaml @@ -0,0 +1,12 @@ + + + + + + diff --git a/src/AvParser.Desktop/App.axaml.cs b/src/AvParser.Desktop/App.axaml.cs new file mode 100644 index 0000000..3664d14 --- /dev/null +++ b/src/AvParser.Desktop/App.axaml.cs @@ -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; + +/// The Avalonia application. Owns nothing but wiring. +/// +/// The container arrives through the constructor rather than a static field, so the XAML +/// previewer and any test host can construct an that has no container at all +/// and simply skips the composition step. +/// +public partial class App : Application +{ + private readonly IServiceProvider? _services; + + /// Parameterless constructor used by the XAML previewer. + public App() + : this(null) { } + + /// Creates the application over a built container. + /// The container, or for design/preview mode. + public App(IServiceProvider? services) => _services = services; + + /// + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + /// + public override void OnFrameworkInitializationCompleted() + { + if (Design.IsDesignMode || _services is null) + { + base.OnFrameworkInitializationCompleted(); + return; + } + + DataTemplates.Add(_services.GetRequiredService()); + + // Resolving the theme service applies the persisted variant as a side effect of construction. + _ = _services.GetRequiredService(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var settings = _services.GetRequiredService(); + 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(), + 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; + } +} diff --git a/src/AvParser.Desktop/AvParser.Desktop.csproj b/src/AvParser.Desktop/AvParser.Desktop.csproj new file mode 100644 index 0000000..59424f6 --- /dev/null +++ b/src/AvParser.Desktop/AvParser.Desktop.csproj @@ -0,0 +1,35 @@ + + + WinExe + AvParser.Desktop + AvParser + true + app.manifest + win-x64;linux-x64;osx-x64;osx-arm64 + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.Desktop/Logging/SerilogExceptionHandler.cs b/src/AvParser.Desktop/Logging/SerilogExceptionHandler.cs new file mode 100644 index 0000000..850d751 --- /dev/null +++ b/src/AvParser.Desktop/Logging/SerilogExceptionHandler.cs @@ -0,0 +1,22 @@ +using Serilog; + +namespace AvParser.Desktop.Logging; + +/// +/// Catches exceptions ReactiveUI would otherwise rethrow on the scheduler and kill the process with. +/// +/// +/// Must be installed while ReactiveUI is being configured, i.e. before the first +/// ReactiveCommand is constructed. Installing it later leaves already-built commands on +/// the default handler. +/// +internal sealed class SerilogExceptionHandler(ILogger logger) : IObserver +{ + 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() { } +} diff --git a/src/AvParser.Desktop/Program.cs b/src/AvParser.Desktop/Program.cs new file mode 100644 index 0000000..4eebb89 --- /dev/null +++ b/src/AvParser.Desktop/Program.cs @@ -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; + +/// Composition root and process entry point. +internal static class Program +{ + /// Builds the container, starts Avalonia, and flushes logs on the way out. + [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(); + 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(); + } + } + + /// + /// Entry point the XAML previewer reflects for. + /// + /// + /// 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 + /// GetMethod("BuildAvaloniaApp") throw . + /// Hence the distinct name for the real builder below. + /// + 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))); +} diff --git a/src/AvParser.Desktop/app.manifest b/src/AvParser.Desktop/app.manifest new file mode 100644 index 0000000..96ccd67 --- /dev/null +++ b/src/AvParser.Desktop/app.manifest @@ -0,0 +1,20 @@ + + + + + + + + PerMonitorV2 + true + + + + + + + + + + diff --git a/src/AvParser.Infrastructure/AvParser.Infrastructure.csproj b/src/AvParser.Infrastructure/AvParser.Infrastructure.csproj new file mode 100644 index 0000000..2ea6d1b --- /dev/null +++ b/src/AvParser.Infrastructure/AvParser.Infrastructure.csproj @@ -0,0 +1,19 @@ + + + AvParser.Infrastructure + + + + + + + + + + + + + + + + diff --git a/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs new file mode 100644 index 0000000..b1fba01 --- /dev/null +++ b/src/AvParser.Infrastructure/DependencyInjection/InfrastructureServiceCollectionExtensions.cs @@ -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; + +/// Composition root for the infrastructure layer. +public static class InfrastructureServiceCollectionExtensions +{ + /// Registers filesystem paths and the persisted settings service. + /// The collection to add to. + /// + /// Explicit paths, or to use the current user's application-data folder. + /// Tests pass a temp directory here. + /// + public static IServiceCollection AddAvParserInfrastructure(this IServiceCollection services, AppPaths? paths = null) + { + ArgumentNullException.ThrowIfNull(services); + + var resolved = paths ?? new AppPaths(); + resolved.EnsureCreated(); + + services.AddSingleton(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(sp => new JsonSettingsService( + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + + return services; + } +} diff --git a/src/AvParser.Infrastructure/Logging/AppLogging.cs b/src/AvParser.Infrastructure/Logging/AppLogging.cs new file mode 100644 index 0000000..60a41d8 --- /dev/null +++ b/src/AvParser.Infrastructure/Logging/AppLogging.cs @@ -0,0 +1,60 @@ +using AvParser.Infrastructure.Storage; +using Serilog; +using Serilog.Core; +using Serilog.Events; + +namespace AvParser.Infrastructure.Logging; + +/// Builds the application's Serilog pipeline. +public static class AppLogging +{ + private const string OutputTemplate = + "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}"; + + /// + /// Creates a console + rolling-file logger writing into . + /// + /// Where log files go. + /// Serilog level name; unrecognised values fall back to Information. + /// + /// The level is exposed through a so the Settings page can + /// change it at runtime without rebuilding the pipeline or restarting the app. + /// + 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); + } + + /// Parses a Serilog level name, defaulting to . + public static LogEventLevel ParseLevel(string? name) => + Enum.TryParse(name, ignoreCase: true, out var level) ? level : LogEventLevel.Information; + + /// The level names offered in the Settings page, ordered from most to least verbose. + public static IReadOnlyList AvailableLevels { get; } = + [ + nameof(LogEventLevel.Verbose), + nameof(LogEventLevel.Debug), + nameof(LogEventLevel.Information), + nameof(LogEventLevel.Warning), + nameof(LogEventLevel.Error), + nameof(LogEventLevel.Fatal), + ]; +} diff --git a/src/AvParser.Infrastructure/Settings/AppSettingsJsonContext.cs b/src/AvParser.Infrastructure/Settings/AppSettingsJsonContext.cs new file mode 100644 index 0000000..fb7504d --- /dev/null +++ b/src/AvParser.Infrastructure/Settings/AppSettingsJsonContext.cs @@ -0,0 +1,14 @@ +using System.Text.Json.Serialization; +using AvParser.Core.Settings; + +namespace AvParser.Infrastructure.Settings; + +/// Source-generated serialiser metadata for . +/// Keeps settings IO reflection-free, which matters if the app is ever trimmed or AOT-published. +[JsonSourceGenerationOptions( + WriteIndented = true, + UseStringEnumConverter = true, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase +)] +[JsonSerializable(typeof(AppSettings))] +internal sealed partial class AppSettingsJsonContext : JsonSerializerContext; diff --git a/src/AvParser.Infrastructure/Settings/JsonSettingsService.cs b/src/AvParser.Infrastructure/Settings/JsonSettingsService.cs new file mode 100644 index 0000000..643146a --- /dev/null +++ b/src/AvParser.Infrastructure/Settings/JsonSettingsService.cs @@ -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; + +/// +/// Persists to a JSON file, debouncing writes. +/// +/// +/// Window resizes and slider drags produce a burst of updates; writing each one would hammer +/// the disk for no benefit. Updates are coalesced over and the +/// final state is written atomically (temp file + move) so a crash mid-write cannot leave a +/// truncated settings file behind. +/// +public sealed class JsonSettingsService : ISettingsService, IDisposable +{ + /// How long to wait for the update burst to settle before writing. + public static readonly TimeSpan SaveDebounce = TimeSpan.FromSeconds(1); + + private readonly IAppPaths _paths; + private readonly ILogger _logger; + private readonly BehaviorSignal _current; + private readonly Signal _saveRequests = new(); + private readonly IDisposable _saveSubscription; + private readonly SemaphoreSlim _writeLock = new(1, 1); + private readonly Lock _gate = new(); + + /// Loads settings from disk, falling back to defaults on any problem. + public JsonSettingsService(IAppPaths paths, ILogger logger, ISequencer? saveScheduler = null) + { + _paths = paths ?? throw new ArgumentNullException(nameof(paths)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _current = new BehaviorSignal(Load()); + + _saveSubscription = _saveRequests + .Throttle(SaveDebounce, saveScheduler ?? TaskPoolSequencer.Instance) + .Subscribe(settings => _ = SaveAsync(settings, CancellationToken.None)); + } + + /// + public AppSettings Current => _current.Value; + + /// + public IObservable Changes => _current; + + /// + public void Update(Func 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); + } + + /// + public Task FlushAsync(CancellationToken cancellationToken = default) => + SaveAsync(_current.Value, cancellationToken); + + /// + 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(); + } + } +} diff --git a/src/AvParser.Infrastructure/Storage/AppPaths.cs b/src/AvParser.Infrastructure/Storage/AppPaths.cs new file mode 100644 index 0000000..7fdddef --- /dev/null +++ b/src/AvParser.Infrastructure/Storage/AppPaths.cs @@ -0,0 +1,66 @@ +namespace AvParser.Infrastructure.Storage; + +/// Resolves the per-user directories the app writes to. +/// +/// 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. +/// +public interface IAppPaths +{ + /// Root of the per-user data directory. Created on demand. + string DataDirectory { get; } + + /// Full path of the settings file. + string SettingsFile { get; } + + /// Directory holding rolling log files. + string LogDirectory { get; } +} + +/// +/// +/// Uses , which maps to +/// %APPDATA% on Windows and ~/.config on Linux/macOS. +/// +public sealed class AppPaths : IAppPaths +{ + private const string FolderName = "AvParser"; + + /// Creates paths under the current user's application-data directory. + public AppPaths() + : this( + Path.Combine( + Environment.GetFolderPath( + Environment.SpecialFolder.ApplicationData, + Environment.SpecialFolderOption.Create + ), + FolderName + ) + ) { } + + /// Creates paths under an explicit root. Used by tests. + public AppPaths(string dataDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory); + + DataDirectory = dataDirectory; + SettingsFile = Path.Combine(dataDirectory, "settings.json"); + LogDirectory = Path.Combine(dataDirectory, "logs"); + } + + /// + public string DataDirectory { get; } + + /// + public string SettingsFile { get; } + + /// + public string LogDirectory { get; } + + /// Creates every directory this instance points at. + public void EnsureCreated() + { + Directory.CreateDirectory(DataDirectory); + Directory.CreateDirectory(LogDirectory); + } +} diff --git a/src/AvParser.UI/AvParser.UI.csproj b/src/AvParser.UI/AvParser.UI.csproj new file mode 100644 index 0000000..9b4fdb7 --- /dev/null +++ b/src/AvParser.UI/AvParser.UI.csproj @@ -0,0 +1,26 @@ + + + AvParser.UI + + true + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Converters/AppConverters.cs b/src/AvParser.UI/Converters/AppConverters.cs new file mode 100644 index 0000000..2cedad9 --- /dev/null +++ b/src/AvParser.UI/Converters/AppConverters.cs @@ -0,0 +1,35 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Data.Converters; +using Avalonia.Media; + +namespace AvParser.UI.Converters; + +/// Small one-way converters used by the views. +public static class AppConverters +{ + /// Collection counts to a boolean, for showing a panel only when it has content. + public static readonly FuncValueConverter IsPositive = new(static count => count > 0); + + /// Inverts a boolean, for enabling a control while a command is idle. + public static readonly FuncValueConverter Not = new(static value => !value); + + /// Formats a 0..1 fraction as a whole-number percentage. + public static readonly FuncValueConverter Percent = new(static value => + value.ToString("P0", CultureInfo.CurrentCulture) + ); + + /// + /// Resolves an icon key from Styles/Icons.axaml to the geometry it names. + /// + /// + /// Lets view models refer to icons by a plain string instead of holding + /// instances, which keeps them trivially constructible in tests. + /// + public static readonly FuncValueConverter IconKeyToGeometry = new(static key => + key is not null && Application.Current is { } app && app.TryFindResource(key, out var resource) + ? resource as Geometry + : null + ); +} diff --git a/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs new file mode 100644 index 0000000..e82260b --- /dev/null +++ b/src/AvParser.UI/DependencyInjection/UiServiceCollectionExtensions.cs @@ -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; + +/// Composition root for the presentation layer. +public static class UiServiceCollectionExtensions +{ + /// Registers the view locator, shell services and every page. + /// + /// Pages are registered twice on purpose: once under their concrete type (so tests and other + /// pages can ask for one specifically) and once under in the order + /// they should appear in the navigation rail. + /// + public static IServiceCollection AddAvParserUI(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(static sp => new ParseViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>() + )); + services.AddSingleton(static sp => new SettingsViewModel( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService() + )); + services.AddSingleton(); + + // Order here is the order of the navigation rail; the first entry is the landing page. + services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); + services.AddSingleton(static sp => sp.GetRequiredService()); + + services.AddSingleton(static sp => new ShellViewModel( + sp.GetRequiredService(), + sp.GetRequiredService() + )); + + return services; + } +} diff --git a/src/AvParser.UI/Navigation/INavigationService.cs b/src/AvParser.UI/Navigation/INavigationService.cs new file mode 100644 index 0000000..0914762 --- /dev/null +++ b/src/AvParser.UI/Navigation/INavigationService.cs @@ -0,0 +1,36 @@ +using AvParser.UI.ViewModels; + +namespace AvParser.UI.Navigation; + +/// Drives which page the shell shows, and keeps a back stack. +/// +/// Deliberately not ReactiveUI's : that requires every page +/// to implement IRoutableViewModel and resolves views through Splat's locator, which would +/// reintroduce a second dependency-resolution path alongside Microsoft.Extensions.DependencyInjection. +/// This interface resolves nothing itself — pages are injected — so it is testable without Avalonia. +/// +public interface INavigationService +{ + /// Every top-level destination, in the order they appear in the rail. + IReadOnlyList Pages { get; } + + /// The page currently displayed. + PageViewModel Current { get; } + + /// Emits the current page, starting with the present value. + IObservable CurrentChanges { get; } + + /// Emits whether would do anything. + IObservable CanGoBack { get; } + + /// Navigates to an already-resolved page, pushing the previous one onto the back stack. + void NavigateTo(PageViewModel page); + + /// Navigates to the registered page of the given type. + /// No page of that type is registered. + void NavigateTo() + where TPage : PageViewModel; + + /// Pops the back stack. Does nothing when the stack is empty. + void GoBack(); +} diff --git a/src/AvParser.UI/Navigation/NavigationService.cs b/src/AvParser.UI/Navigation/NavigationService.cs new file mode 100644 index 0000000..d71989d --- /dev/null +++ b/src/AvParser.UI/Navigation/NavigationService.cs @@ -0,0 +1,86 @@ +using AvParser.UI.ViewModels; +using ReactiveUI.Primitives.Signals; + +namespace AvParser.UI.Navigation; + +/// +public sealed class NavigationService : INavigationService, IDisposable +{ + private readonly Stack _backStack = new(); + private readonly BehaviorSignal _current; + private readonly BehaviorSignal _canGoBack = new(false); + + /// Creates the service over the pages the container resolved. + /// Registration order becomes rail order; the first page is the landing page. + /// No pages were registered. + public NavigationService(IEnumerable 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(Pages[0]); + } + + /// + public IReadOnlyList Pages { get; } + + /// + public PageViewModel Current => _current.Value; + + /// + public IObservable CurrentChanges => _current; + + /// + public IObservable CanGoBack => _canGoBack; + + /// + public void NavigateTo(PageViewModel page) + { + ArgumentNullException.ThrowIfNull(page); + + if (ReferenceEquals(page, _current.Value)) + { + return; + } + + _backStack.Push(_current.Value); + _current.OnNext(page); + _canGoBack.OnNext(true); + } + + /// + public void NavigateTo() + where TPage : PageViewModel + { + var page = + Pages.OfType().FirstOrDefault() + ?? throw new InvalidOperationException($"No page of type {typeof(TPage).Name} is registered."); + + NavigateTo(page); + } + + /// + public void GoBack() + { + if (!_backStack.TryPop(out var previous)) + { + return; + } + + _current.OnNext(previous); + _canGoBack.OnNext(_backStack.Count > 0); + } + + /// + public void Dispose() + { + _current.Dispose(); + _canGoBack.Dispose(); + } +} diff --git a/src/AvParser.UI/Responsive/Breakpoint.cs b/src/AvParser.UI/Responsive/Breakpoint.cs new file mode 100644 index 0000000..335c1ac --- /dev/null +++ b/src/AvParser.UI/Responsive/Breakpoint.cs @@ -0,0 +1,14 @@ +namespace AvParser.UI.Responsive; + +/// Width class the shell adapts to. Named after the WinUI/Material size classes. +public enum Breakpoint +{ + /// Phone-width or a heavily shrunk window: navigation becomes an overlay drawer. + Compact, + + /// Tablet-width: navigation collapses to an icon rail. + Medium, + + /// Desktop-width: navigation is a full inline sidebar with labels. + Expanded, +} diff --git a/src/AvParser.UI/Responsive/ResponsiveLayout.cs b/src/AvParser.UI/Responsive/ResponsiveLayout.cs new file mode 100644 index 0000000..9b8922b --- /dev/null +++ b/src/AvParser.UI/Responsive/ResponsiveLayout.cs @@ -0,0 +1,133 @@ +using Avalonia; +using Avalonia.Controls; +using ReactiveUI.Primitives; + +namespace AvParser.UI.Responsive; + +/// +/// Breakpoint engine: watches a control's width and projects a onto +/// both an attached property and :compact / :medium / :expanded pseudoclasses. +/// +/// +/// +/// Avalonia has no AdaptiveTrigger or VisualStateManager, and no CSS media queries. +/// The three primitives that do exist are (observable), +/// pseudoclasses (settable from code, usable in selectors) and . This class +/// wires the first onto the second so that XAML can style by width the way CSS would. +/// +/// +/// Enable it with r:ResponsiveLayout.IsEnabled="True" on the shell, then select on +/// UserControl.shell:compact ... in styles. +/// +/// +public static class ResponsiveLayout +{ + /// Widths below this are . + public const double MediumMinWidth = 720d; + + /// Widths at or above this are . + public const double ExpandedMinWidth = 1100d; + + /// + /// Deadband applied to the band the control is already in, in device-independent pixels. + /// + /// + /// Without it, dragging a resize grip across a boundary makes the layout flap between two + /// states on every pixel of jitter. + /// + public const double Hysteresis = 24d; + + /// Set to to start observing width on this control. + public static readonly AttachedProperty IsEnabledProperty = AvaloniaProperty.RegisterAttached( + "IsEnabled", + typeof(ResponsiveLayout) + ); + + /// The current breakpoint. Read-only in practice: written by this class. + /// Inherits down the visual tree, so any descendant can bind to it. + public static readonly AttachedProperty BreakpointProperty = AvaloniaProperty.RegisterAttached< + Control, + Breakpoint + >("Breakpoint", typeof(ResponsiveLayout), Breakpoint.Expanded, inherits: true); + + private static readonly AttachedProperty SubscriptionProperty = AvaloniaProperty.RegisterAttached< + Control, + IDisposable? + >("Subscription", typeof(ResponsiveLayout)); + + static ResponsiveLayout() => IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); + + /// Gets whether width observation is enabled. + public static bool GetIsEnabled(Control control) => control.GetValue(IsEnabledProperty); + + /// Enables or disables width observation. + public static void SetIsEnabled(Control control, bool value) => control.SetValue(IsEnabledProperty, value); + + /// Gets the control's current breakpoint. + public static Breakpoint GetBreakpoint(Control control) => control.GetValue(BreakpointProperty); + + /// + /// Maps a width to a breakpoint, widening whichever band is already + /// in by . + /// + 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; + } + + /// Writes the breakpoint and its pseudoclasses onto a control. + /// Public so headless tests can drive a control without a live layout pass. + 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()) + { + 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); + } +} diff --git a/src/AvParser.UI/Services/IThemeService.cs b/src/AvParser.UI/Services/IThemeService.cs new file mode 100644 index 0000000..2340c0c --- /dev/null +++ b/src/AvParser.UI/Services/IThemeService.cs @@ -0,0 +1,16 @@ +using AvParser.Core.Settings; + +namespace AvParser.UI.Services; + +/// Applies and persists the light/dark/system theme choice. +public interface IThemeService +{ + /// The theme currently in effect. + AppTheme Current { get; } + + /// Emits the theme, starting with the present value. + IObservable Changes { get; } + + /// Applies a theme to the running application and persists the choice. + void Apply(AppTheme theme); +} diff --git a/src/AvParser.UI/Services/ThemeService.cs b/src/AvParser.UI/Services/ThemeService.cs new file mode 100644 index 0000000..6477331 --- /dev/null +++ b/src/AvParser.UI/Services/ThemeService.cs @@ -0,0 +1,62 @@ +using Avalonia; +using Avalonia.Styling; +using AvParser.Core.Settings; +using ReactiveUI.Primitives.Signals; + +namespace AvParser.UI.Services; + +/// +public sealed class ThemeService : IThemeService, IDisposable +{ + private readonly ISettingsService _settings; + private readonly BehaviorSignal _current; + + /// Restores the persisted theme and applies it immediately. + public ThemeService(ISettingsService settings) + { + _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + _current = new BehaviorSignal(settings.Current.Theme); + + ApplyToApplication(settings.Current.Theme); + } + + /// + public AppTheme Current => _current.Value; + + /// + public IObservable Changes => _current; + + /// + public void Apply(AppTheme theme) + { + if (theme == _current.Value) + { + return; + } + + ApplyToApplication(theme); + _current.OnNext(theme); + _settings.Update(current => current with { Theme = theme }); + } + + /// + public void Dispose() => _current.Dispose(); + + /// Maps the app's theme enum onto Avalonia's variant. + 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); + } + } +} diff --git a/src/AvParser.UI/Styles/Controls.axaml b/src/AvParser.UI/Styles/Controls.axaml new file mode 100644 index 0000000..32bfae9 --- /dev/null +++ b/src/AvParser.UI/Styles/Controls.axaml @@ -0,0 +1,151 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Styles/Icons.axaml b/src/AvParser.UI/Styles/Icons.axaml new file mode 100644 index 0000000..14c38ac --- /dev/null +++ b/src/AvParser.UI/Styles/Icons.axaml @@ -0,0 +1,45 @@ + + + + M12 3 2 12h3v8h6v-6h2v6h6v-8h3L12 3z + + + M6 2h9l5 5v15H6V2zm8 1.5V8h4.5L14 3.5zM8 12h8v1.6H8V12zm0 3.4h8V17H8v-1.6z + + + + 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 + + + + M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z + + + M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z + + M20 11H7.8l5.6-5.6L12 4l-8 8 8 8 1.4-1.4L7.8 13H20v-2z + + M8 5v14l11-7z + + M6.5 6.5h11v11h-11z + + + M4 20h16v-1.6H4V20zm3.6-3.6h8.8l-1.2-5.2-2-1V4.4h-2.4v5.8l-2 1-1.2 5.2z + + + + 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 + + + M12.4 3a9 9 0 1 0 8.6 11.2A7 7 0 0 1 12.4 3z + + M12 2 1 21h22L12 2zm1 14.2h-2v-2h2v2zm0-3.8h-2V8.6h2v3.8z + + + 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 + + diff --git a/src/AvParser.UI/Styles/Index.axaml b/src/AvParser.UI/Styles/Index.axaml new file mode 100644 index 0000000..ca37181 --- /dev/null +++ b/src/AvParser.UI/Styles/Index.axaml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Styles/Shell.axaml b/src/AvParser.UI/Styles/Shell.axaml new file mode 100644 index 0000000..25d0b15 --- /dev/null +++ b/src/AvParser.UI/Styles/Shell.axaml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Styles/Tokens.axaml b/src/AvParser.UI/Styles/Tokens.axaml new file mode 100644 index 0000000..9a73fe5 --- /dev/null +++ b/src/AvParser.UI/Styles/Tokens.axaml @@ -0,0 +1,71 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 4 + 8 + 12 + 16 + 24 + 32 + + 24 + 12 + 16 + 16,10 + + + 4 + 8 + 12 + + + 28 + 20 + 15 + 13 + 12 + + + 248 + 56 + 16 + diff --git a/src/AvParser.UI/ViewLocator.cs b/src/AvParser.UI/ViewLocator.cs new file mode 100644 index 0000000..97146df --- /dev/null +++ b/src/AvParser.UI/ViewLocator.cs @@ -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; + +/// +/// Maps a view model to its view by naming convention and builds it through the container. +/// +/// +/// +/// AvParser.UI.ViewModels.SettingsViewModel resolves to AvParser.UI.Views.SettingsView. +/// The namespace substitution must run before the type-name one, otherwise +/// ViewModels.XViewModel becomes Views.XView only by accident. +/// +/// +/// Registered from code rather than declared in App.axaml: a XAML-declared instance would +/// need a parameterless constructor and could never see . +/// +/// +public sealed class ViewLocator(IServiceProvider services) : IDataTemplate +{ + private static readonly ConcurrentDictionary ViewTypeCache = new(); + + private readonly IServiceProvider _services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + public bool Match(object? data) => data is ViewModelBase; + + /// + 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; + } +} diff --git a/src/AvParser.UI/ViewModels/AboutViewModel.cs b/src/AvParser.UI/ViewModels/AboutViewModel.cs new file mode 100644 index 0000000..7065fcd --- /dev/null +++ b/src/AvParser.UI/ViewModels/AboutViewModel.cs @@ -0,0 +1,72 @@ +using System.Reflection; +using AvParser.Infrastructure.Storage; + +namespace AvParser.UI.ViewModels; + +/// One row of the "built with" table. +/// Component name. +/// Version or a one-line note. +public sealed record ComponentInfo(string Name, string Detail); + +/// Version, runtime and stack information. +public sealed class AboutViewModel : PageViewModel +{ + /// Creates the page. + public AboutViewModel(IAppPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + var assembly = typeof(AboutViewModel).Assembly; + + Version = + assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "unknown"; + + // Source-built informational versions carry a "+" 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")), + ]; + } + + /// + public override string Title => "About"; + + /// + public override string IconKey => "IconInfo"; + + /// Informational version of the UI assembly. + public string Version { get; } + + /// Root of the per-user data directory. + public string DataDirectory { get; } + + /// Where rolling log files are written. + public string LogDirectory { get; } + + /// The stack this build is running on. + public IReadOnlyList 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"; + } +} diff --git a/src/AvParser.UI/ViewModels/DashboardViewModel.cs b/src/AvParser.UI/ViewModels/DashboardViewModel.cs new file mode 100644 index 0000000..a208fc3 --- /dev/null +++ b/src/AvParser.UI/ViewModels/DashboardViewModel.cs @@ -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; + +/// Landing page: what is registered, where data lives, and shortcuts into the app. +public sealed class DashboardViewModel : PageViewModel +{ + private readonly IServiceProvider _services; + + /// Creates the dashboard. + /// Registered parsers, shown as cards. + /// Where the app writes settings and logs. + /// + /// Used to resolve 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. + /// + 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()); + GoToSettingsCommand = ReactiveCommand.Create(() => Navigate()); + } + + /// + public override string Title => "Dashboard"; + + /// + public override string IconKey => "IconHome"; + + /// Registered parsers, shown as cards. + public IReadOnlyList Parsers { get; } + + /// Where settings and logs are written. + public string DataDirectory { get; } + + /// Jumps to the Parse page. + public ReactiveCommand GoToParseCommand { get; } + + /// Jumps to the Settings page. + public ReactiveCommand GoToSettingsCommand { get; } + + private void Navigate() + where TPage : PageViewModel => _services.GetRequiredService().NavigateTo(); +} diff --git a/src/AvParser.UI/ViewModels/ParseViewModel.cs b/src/AvParser.UI/ViewModels/ParseViewModel.cs new file mode 100644 index 0000000..980fc58 --- /dev/null +++ b/src/AvParser.UI/ViewModels/ParseViewModel.cs @@ -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; + +/// Runs a parser over pasted text and streams the results into the UI. +/// +/// This page exists to exercise the whole contract — +/// streaming, progress and cancellation — rather than to be a finished feature. +/// +public partial class ParseViewModel : PageViewModel +{ + /// Records buffered before being pushed to the UI collection in one go. + private const int BatchSize = 512; + + /// + /// 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. + /// + private const int MaxDisplayedRecords = 20_000; + + private readonly IParserCatalog _catalog; + private readonly ISettingsService _settings; + private readonly ILogger _logger; + private readonly ISequencer _mainThread; + private readonly ObservableAsPropertyHelper _isBusy; + + private CancellationTokenSource? _cancellation; + + /// Text to parse. + [Reactive] + public partial string InputText { get; set; } + + /// Parser applied by . + [Reactive] + public partial ITextParser SelectedParser { get; set; } + + /// Completion of the running parse, 0.0 to 1.0. + [Reactive] + public partial double Progress { get; set; } + + /// Outcome summary shown under the toolbar; when idle. + [Reactive] + public partial string? StatusMessage { get; set; } + + /// Creates the page. + /// Available parsers. + /// Used to remember the selected parser. + /// Diagnostics. + /// + /// Scheduler used to marshal collection and progress updates back to the UI thread. Tests + /// pass to make everything synchronous. + /// + public ParseViewModel( + IParserCatalog catalog, + ISettingsService settings, + ILogger 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); + } + + /// + public override string Title => "Parse"; + + /// + public override string IconKey => "IconDocument"; + + /// Every registered parser, for the picker. + public IReadOnlyList Parsers => _catalog.Parsers; + + /// Successfully parsed records, capped at . + public ObservableCollection Records { get; } = []; + + /// Per-line failures. A failure never aborts the parse. + public ObservableCollection Errors { get; } = []; + + /// Whether a parse is currently running. + public bool IsBusy => _isBusy.Value; + + /// Runs over . + public ReactiveCommand ParseCommand { get; } + + /// Cancels the running parse. + public ReactiveCommand CancelCommand { get; } + + /// Clears the input and all results. + public ReactiveCommand ClearCommand { get; } + + /// Fills the input with a small example for the selected parser. + public ReactiveCommand LoadSampleCommand { get; } + + /// Fills the input with 50 000 rows, so progress and cancellation are observable. + public ReactiveCommand 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(BatchSize); + var errorBuffer = new List(16); + var progress = new Progress(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 records, List 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}"); + } + + /// Marshals a mutation onto the UI thread; the parse loop runs on the thread pool. + 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(); + } +} diff --git a/src/AvParser.UI/ViewModels/SettingsViewModel.cs b/src/AvParser.UI/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..e1c85e3 --- /dev/null +++ b/src/AvParser.UI/ViewModels/SettingsViewModel.cs @@ -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; + +/// Theme, logging level and where the app keeps its files. +public partial class SettingsViewModel : PageViewModel +{ + private readonly ISettingsService _settings; + private readonly IThemeService _theme; + private readonly LoggingLevelSwitch _levelSwitch; + + /// Selected theme. Applied immediately, not on an OK button. + [Reactive] + public partial AppTheme SelectedTheme { get; set; } + + /// Selected Serilog level name. Takes effect immediately. + [Reactive] + public partial string SelectedLogLevel { get; set; } + + /// Creates the page. + 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); + } + + /// + public override string Title => "Settings"; + + /// + public override string IconKey => "IconSettings"; + + /// Theme options offered by the radio group. + public IReadOnlyList Themes { get; } = [AppTheme.System, AppTheme.Light, AppTheme.Dark]; + + /// Serilog level names, most to least verbose. + public IReadOnlyList LogLevels => AppLogging.AvailableLevels; + + /// Full path of the settings file. + public string SettingsFile { get; } + + /// Directory holding rolling log files. + public string LogDirectory { get; } + + /// Width in pixels at which the shell switches from compact to the icon rail. + public double MediumBreakpoint => ResponsiveLayout.MediumMinWidth; + + /// Width in pixels at which the shell switches to the full sidebar. + public double ExpandedBreakpoint => ResponsiveLayout.ExpandedMinWidth; + + private void ApplyLogLevel(string level) + { + _levelSwitch.MinimumLevel = AppLogging.ParseLevel(level); + _settings.Update(current => current with { MinimumLogLevel = level }); + } +} diff --git a/src/AvParser.UI/ViewModels/ShellViewModel.cs b/src/AvParser.UI/ViewModels/ShellViewModel.cs new file mode 100644 index 0000000..af2bb31 --- /dev/null +++ b/src/AvParser.UI/ViewModels/ShellViewModel.cs @@ -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; + +/// The application shell: navigation rail, title bar and the hosted page. +/// +/// Pane state lives here rather than in a style setter. A style Setter loses to a local +/// value permanently, so the first hamburger click would otherwise freeze the breakpoint styles. +/// Styles own DisplayMode and the pane lengths; this view model owns . +/// +public partial class ShellViewModel : ViewModelBase +{ + private readonly INavigationService _navigation; + private readonly IThemeService _theme; + private readonly ObservableAsPropertyHelper _paneDisplayMode; + private readonly ObservableAsPropertyHelper _currentPage; + private readonly ObservableAsPropertyHelper _title; + private readonly ObservableAsPropertyHelper _canGoBack; + private readonly ObservableAsPropertyHelper _themeIconKey; + + /// Current width class. Written by the view as the window resizes. + [Reactive] + public partial Breakpoint Breakpoint { get; set; } + + /// Whether the navigation pane is open. + [Reactive] + public partial bool IsPaneOpen { get; set; } + + /// The rail's selected entry. Two-way bound to the navigation list. + [Reactive] + public partial PageViewModel SelectedPage { get; set; } + + /// Creates the shell over the registered pages. + /// Page stack. + /// Theme switching. + /// + /// Scheduler for derived properties. Tests pass + /// so assertions can run without a dispatcher. + /// + 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 + ); + } + + /// Every top-level destination, for the rail. + public IReadOnlyList Pages => _navigation.Pages; + + /// The page hosted in the content area. + public PageViewModel CurrentPage => _currentPage.Value; + + /// Title of the current page. + public string Title => _title.Value; + + /// Whether the back button is enabled. + public bool CanGoBack => _canGoBack.Value; + + /// How the navigation pane is laid out at the current breakpoint. + public SplitViewDisplayMode PaneDisplayMode => _paneDisplayMode.Value; + + /// Icon key for the theme toggle: a sun in dark mode, a moon in light mode. + public string ThemeIconKey => _themeIconKey.Value; + + /// Opens or closes the navigation pane. + public ReactiveCommand TogglePaneCommand { get; } + + /// Pops the navigation back stack. + public ReactiveCommand GoBackCommand { get; } + + /// Flips between the light and dark theme. + public ReactiveCommand ToggleThemeCommand { get; } +} diff --git a/src/AvParser.UI/ViewModels/ViewModelBase.cs b/src/AvParser.UI/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..cfbb1da --- /dev/null +++ b/src/AvParser.UI/ViewModels/ViewModelBase.cs @@ -0,0 +1,27 @@ +using ReactiveUI; + +namespace AvParser.UI.ViewModels; + +/// Base for every view model in the app. +/// +/// gives views a WhenActivated 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. +/// +public abstract class ViewModelBase : ReactiveObject, IActivatableViewModel +{ + /// + public ViewModelActivator Activator { get; } = new(); +} + +/// A view model that appears as a top-level destination in the navigation rail. +public abstract class PageViewModel : ViewModelBase +{ + /// Label shown in the sidebar and the title bar. + public abstract string Title { get; } + + /// + /// Key of a StreamGeometry in Styles/Icons.axaml used as the rail icon. + /// + public abstract string IconKey { get; } +} diff --git a/src/AvParser.UI/Views/AboutView.axaml b/src/AvParser.UI/Views/AboutView.axaml new file mode 100644 index 0000000..43b48f8 --- /dev/null +++ b/src/AvParser.UI/Views/AboutView.axaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/AboutView.axaml.cs b/src/AvParser.UI/Views/AboutView.axaml.cs new file mode 100644 index 0000000..19c7563 --- /dev/null +++ b/src/AvParser.UI/Views/AboutView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Version and environment information. +public partial class AboutView : UserControl +{ + /// Creates the view. + public AboutView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/AvParser.UI/Views/DashboardView.axaml b/src/AvParser.UI/Views/DashboardView.axaml new file mode 100644 index 0000000..80daa9f --- /dev/null +++ b/src/AvParser.UI/Views/DashboardView.axaml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/DashboardView.axaml.cs b/src/AvParser.UI/Views/DashboardView.axaml.cs new file mode 100644 index 0000000..12667c1 --- /dev/null +++ b/src/AvParser.UI/Views/DashboardView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Landing page. +public partial class DashboardView : UserControl +{ + /// Creates the view. + public DashboardView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/AvParser.UI/Views/MainWindow.axaml b/src/AvParser.UI/Views/MainWindow.axaml new file mode 100644 index 0000000..075da8e --- /dev/null +++ b/src/AvParser.UI/Views/MainWindow.axaml @@ -0,0 +1,19 @@ + + + + diff --git a/src/AvParser.UI/Views/MainWindow.axaml.cs b/src/AvParser.UI/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..58f7f76 --- /dev/null +++ b/src/AvParser.UI/Views/MainWindow.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// The application window. Hosts and nothing else. +public partial class MainWindow : Window +{ + /// Creates the window. + public MainWindow() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/AvParser.UI/Views/ParseView.axaml b/src/AvParser.UI/Views/ParseView.axaml new file mode 100644 index 0000000..206d7fd --- /dev/null +++ b/src/AvParser.UI/Views/ParseView.axaml @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/ParseView.axaml.cs b/src/AvParser.UI/Views/ParseView.axaml.cs new file mode 100644 index 0000000..bb84f8d --- /dev/null +++ b/src/AvParser.UI/Views/ParseView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Input, toolbar and streamed parse results. +public partial class ParseView : UserControl +{ + /// Creates the view. + public ParseView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/AvParser.UI/Views/SettingsView.axaml b/src/AvParser.UI/Views/SettingsView.axaml new file mode 100644 index 0000000..5ac9694 --- /dev/null +++ b/src/AvParser.UI/Views/SettingsView.axaml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/SettingsView.axaml.cs b/src/AvParser.UI/Views/SettingsView.axaml.cs new file mode 100644 index 0000000..388f9aa --- /dev/null +++ b/src/AvParser.UI/Views/SettingsView.axaml.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace AvParser.UI.Views; + +/// Theme, logging and paths. +public partial class SettingsView : UserControl +{ + /// Creates the view. + public SettingsView() => InitializeComponent(); + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +} diff --git a/src/AvParser.UI/Views/ShellView.axaml b/src/AvParser.UI/Views/ShellView.axaml new file mode 100644 index 0000000..697fd04 --- /dev/null +++ b/src/AvParser.UI/Views/ShellView.axaml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AvParser.UI/Views/ShellView.axaml.cs b/src/AvParser.UI/Views/ShellView.axaml.cs new file mode 100644 index 0000000..7ea8d0c --- /dev/null +++ b/src/AvParser.UI/Views/ShellView.axaml.cs @@ -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; + +/// Hosts the navigation rail, the title bar and the current page. +public partial class ShellView : ReactiveUserControl +{ + /// Creates the view and starts feeding breakpoint changes to the view model. + 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); +} diff --git a/tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj b/tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj new file mode 100644 index 0000000..e4aa8ea --- /dev/null +++ b/tests/AvParser.Core.Tests/AvParser.Core.Tests.csproj @@ -0,0 +1,9 @@ + + + AvParser.Core.Tests + + + + + + diff --git a/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs b/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs new file mode 100644 index 0000000..6ce9570 --- /dev/null +++ b/tests/AvParser.Core.Tests/DelimitedTextParserTests.cs @@ -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(); + + // Not Progress: it posts to the captured synchronization context, so the reports would + // arrive after the assertions. A direct IProgress keeps the test deterministic. + await foreach ( + var _ in _parser.ParseAsync( + ParserTestExtensions.DelimitedDocument(900), + new SynchronousProgress(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(); + } + + [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); +} diff --git a/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs b/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs new file mode 100644 index 0000000..898a7bf --- /dev/null +++ b/tests/AvParser.Core.Tests/KeyValueTextParserTests.cs @@ -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); + } +} diff --git a/tests/AvParser.Core.Tests/ParserCatalogTests.cs b/tests/AvParser.Core.Tests/ParserCatalogTests.cs new file mode 100644 index 0000000..06a9da3 --- /dev/null +++ b/tests/AvParser.Core.Tests/ParserCatalogTests.cs @@ -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(() => new ParserCatalog([])); + + [Fact] + public void Rejects_duplicate_ids() => + Should + .Throw(() => new ParserCatalog([new DelimitedTextParser(), new DelimitedTextParser()])) + .Message.ShouldContain("Duplicate"); +} diff --git a/tests/AvParser.Core.Tests/ParserTestExtensions.cs b/tests/AvParser.Core.Tests/ParserTestExtensions.cs new file mode 100644 index 0000000..39578d8 --- /dev/null +++ b/tests/AvParser.Core.Tests/ParserTestExtensions.cs @@ -0,0 +1,47 @@ +using AvParser.Core.Parsing; + +namespace AvParser.Core.Tests; + +/// Collection helpers so the tests read as assertions rather than as loops. +internal static class ParserTestExtensions +{ + /// + /// Drains a parse into memory, using the ambient test cancellation token. + /// + /// + /// Deliberately takes no : every call site would otherwise have + /// to pass TestContext.Current.CancellationToken to satisfy xUnit1051. Cancellation + /// behaviour is covered by driving directly. + /// + internal static async Task<(List Records, List Errors)> CollectAsync( + this ITextParser parser, + string input, + IProgress? progress = null + ) + { + var records = new List(); + var errors = new List(); + + 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); + } + + /// Reads a field by name, failing the test if it is absent. + internal static string Field(this ParsedRecord record, string name) => + record[name] ?? throw new InvalidOperationException($"Field '{name}' is missing."); + + /// Builds a delimited document with a header plus data rows. + internal static string DelimitedDocument(int rows) => + string.Join('\n', Enumerable.Range(0, rows + 1).Select(i => i == 0 ? "id,name" : $"{i},row{i}")); +} diff --git a/tests/AvParser.Core.Tests/SynchronousProgress.cs b/tests/AvParser.Core.Tests/SynchronousProgress.cs new file mode 100644 index 0000000..a127ce8 --- /dev/null +++ b/tests/AvParser.Core.Tests/SynchronousProgress.cs @@ -0,0 +1,13 @@ +namespace AvParser.Core.Tests; + +/// +/// An that invokes its callback inline. +/// +/// +/// marshals through the captured synchronization context, which makes +/// the delivery order untestable. This one reports on the calling thread. +/// +internal sealed class SynchronousProgress(Action onReport) : IProgress +{ + public void Report(T value) => onReport(value); +} diff --git a/tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj b/tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj new file mode 100644 index 0000000..17935e4 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/AvParser.UI.HeadlessTests.csproj @@ -0,0 +1,22 @@ + + + AvParser.UI.HeadlessTests + + + + + + + + + + + + + + + + + + + diff --git a/tests/AvParser.UI.HeadlessTests/Fakes.cs b/tests/AvParser.UI.HeadlessTests/Fakes.cs new file mode 100644 index 0000000..5da2cc2 --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/Fakes.cs @@ -0,0 +1,41 @@ +using AvParser.Core.Settings; +using AvParser.UI.Services; +using AvParser.UI.ViewModels; +using ReactiveUI.Primitives.Signals; + +namespace AvParser.UI.HeadlessTests; + +/// +/// Test doubles for the headless tests. +/// +/// +/// Intentionally duplicated from AvParser.UI.Tests 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 tests/ conventions (self-executing xUnit exe) to build at all. +/// +internal sealed class FakePage(string title, string iconKey = "IconHome") : PageViewModel +{ + public override string Title { get; } = title; + + public override string IconKey { get; } = iconKey; +} + +/// +internal sealed class FakeThemeService(AppTheme initial = AppTheme.System) : IThemeService, IDisposable +{ + private readonly BehaviorSignal _current = new(initial); + + public AppTheme Current => _current.Value; + + public IObservable Changes => _current; + + public void Apply(AppTheme theme) + { + if (theme != _current.Value) + { + _current.OnNext(theme); + } + } + + public void Dispose() => _current.Dispose(); +} diff --git a/tests/AvParser.UI.HeadlessTests/ResponsiveTests.cs b/tests/AvParser.UI.HeadlessTests/ResponsiveTests.cs new file mode 100644 index 0000000..134d13b --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/ResponsiveTests.cs @@ -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); + } +} diff --git a/tests/AvParser.UI.HeadlessTests/ShellViewTests.cs b/tests/AvParser.UI.HeadlessTests/ShellViewTests.cs new file mode 100644 index 0000000..85eb64f --- /dev/null +++ b/tests/AvParser.UI.HeadlessTests/ShellViewTests.cs @@ -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 +{ + /// + /// Shows the shell inside a window of the requested width. + /// + /// + /// A real is required — a detached control never builds its visual tree, + /// so there would be no 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. + /// + 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().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().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().Single(); + host.Content.ShouldBeSameAs(second); + viewModel.Title.ShouldBe("Second"); + } + + /// + /// Guards against the shell stylesheet silently matching nothing. + /// + /// + /// Avalonia type selectors match the exact type, so UserControl.shell does not match + /// (which derives from ReactiveUserControl<T>). 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. + /// + [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().Single(b => b.Name == "PageHost"); + pageHost.Padding.ShouldBe(new Thickness(24)); + + var paneToggle = view.GetVisualDescendants().OfType