From 74dbfcc7dd5be68baed73373c21e0ce02b7cd10f Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 10:41:08 -0400 Subject: [PATCH 01/14] Add Lexical Edit Avalonia migration plan and coverage Add FieldWorks Avalonia migration review skills Narrow Phase 1-2 Avalonia migration foundation Tests: de-brittle filter/morph type tests - Avoid localized UI strings in BulkEditBarTests filter baseline - Stop using reflection in MorphTypeAtomicLauncherTests - Fix ViewsInterfaces duplicate TargetFrameworkAttribute build docs: consolidate Avalonia migration onto hybrid roadmap and POC spike Sequence the Avalonia migration as one gated path: POC spike -> DataTree region (Plan A) -> Lexical Edit program (Plan B) -> shell. Adds the avalonia-migration-roadmap umbrella change and the lexical-edit-avalonia-poc-spike change (flagged, in-proc net48 dual-run, density/parity evidence). Brings the datatree-model-view-separation plan onto this branch as the first migrated region with a hybrid-alignment note, and records the approach comparison and recommendation. test: add deterministic semantic-snapshot baseline for POC parity Locks the normalized per-slice snapshot (label, field, flid, editor, visibility, focus order, a11y) that the Avalonia POC slice must reproduce, and asserts it is deterministic across realizations. All expected values reuse those already proven by CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder. feat: prove Avalonia-on-net48 POC spike for Lexical Edit Implements lexical-edit-avalonia-poc-spike as an isolated, flag-gated proof of concept under Src/Common/FwAvalonia (intentionally not in the traversal build or solution yet, so it cannot break the default build). Evidence executed on net48 with Avalonia 11.3.17: - restore + build of the library and headless test project succeed - 20 headless/unit tests pass (dotnet test), covering the two-adapter flag (default WinForms; no Avalonia runtime constructed when off), the three-editor slice, fenced commit/cancel, morph-type popup focus return, writing-system fonts, and a no-native/no-Graphite reference audit Pins Avalonia to 11.3.x in CPM because 12.x dropped netstandard2.0 and cannot load on net48; Avalonia.Win32.Interoperability (net461) restores and builds on net48, confirming the in-process embedding path. Live embedding into RecordEditView and DPI density screenshots require the running app and are deferred to the regional migration (Gate 0 follow-up); see spike-evidence.md. Recommendation: GO. feat: add Lexical Edit migration seams and typed view-definition IR Implements sections 3 (refactor seams) and 4 (typed view definition + XML import) of lexical-edit-avalonia-migration as isolated, tested code in Src/Common/FwAvalonia (not in the traversal build yet, like the POC). Seams (framework-neutral contracts + pure implementations + tests): - ILexicalRefreshCoordinator / RefreshCoordinator: pure model of the DataTree DoNotRefresh/RefreshPending gate (LT-22414) - ILexicalEditorRegistry / LexicalEditorRegistry: editor-key boundary in front of SliceFactory with fallback-to-legacy - IEditSession (PocEditSession now implements it); IUiScheduler, IRegionLifetime, IPropertyStateStore implementations - MorphTypeSwapLogic humble object mirroring MorphTypeAtomicLauncher IsStemType and the stem/affix data-loss decision Typed view definition (sections 4.1-4.6): - ViewDefinitionModel/ViewNode immutable IR with deterministic snapshot - XmlLayoutImporter parses the real Parts/Layout schema (parts, grouping slices, obj/seq, indent, custom-field placeholder, visibility/expansion overrides) and raises diagnostics for dynamic/unknown/obsolete editors and unresolved parts, never throwing - EditorKindMap classifies editors faithfully to SliceFactory's switch - ViewDefinitionCompiler/Cache with content-fingerprint keys, invalidation, and cancellable off-thread compile over immutable snapshots Evidence: dotnet test on net48 passes 67 tests (47 new). Live wiring into DataTree/SliceFactory/RecordEditView is deferred to the regional migration; task notes mark contract-vs-wired status honestly. test: add section-2 characterization coverage before refactor Retroactively completes the "test coverage before refactor" tasks (2.5-2.7) of lexical-edit-avalonia-migration with tests that run against the REAL WinForms DataTree/Slice/RenderVerification code on net48, locking current behavior so the Avalonia refactor is protected. DetailControlsTests (real DataTree/Slice, 11 tests): - DataTreeUndoRedoCharacterizationTests: multistring CitationForm/Bibliography edits revert and replay; multiple edits in one task form a single undo step; consecutive edits are distinct steps; a freshly built slice reflects the reverted model. Uses the base action handler + UndoableUnitOfWorkHelper. - DataTreeDisposalCharacterizationTests: Dispose cascades to slices, removes the LCModel notification (no throw on later change), is idempotent, and is safe with a current slice; slices expose AccessibleName == label and a stable focus order (in-process accessibility substitute for 2.4). RenderVerification (failure-artifact bundling, 4 tests): - RenderFailureArtifactBundler bundles received/diff images + a failure-summary.json into a CI-discoverable folder on a failed render/parity verification; wired into DataTreeRenderTests.VerifyDataTreeBitmap. Compiled into RenderTestInfrastructure alongside RenderSnapshotVerifier so the result type is in scope; covered by RenderFailureArtifactBundlerTests. Evidence: dotnet test on net48 passes all 15 new tests. True UIA2/FlaUI baselines (2.4) and keyboard/IME/localization characterization (2.7 remainder) still need a running app host and remain pending; tasks.md notes this honestly. build: make Avalonia path net48-only and align package pins Keep the Avalonia spike strictly on .NET Framework 4.8 for now. - `build.ps1 -BuildAvalonia` now filters optional Avalonia projects to those that target `net48`, skips non-net48 hosts/modules with an explicit message, and avoids double-building the same project when `-Project` already points at an Avalonia csproj. - `Directory.Packages.props` now pins `HarfBuzzSharp` and its Win32 native assets to 8.3.1.1, matching Avalonia 11.3.17 and removing the NU1109 downgrade failure for the net48 Avalonia spike. Validated: - `dotnet msbuild Src/Common/FwAvalonia/FwAvalonia.csproj` with `/t:Restore;Build` succeeds on net48. - `dotnet test Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj` still passes (67 tests). - `build.ps1 -BuildAvalonia` with `-Project` set to `Src/Common/FwAvalonia/FwAvalonia.csproj` succeeds and reports that the existing preview host is net8-based and outside this branch's net48-only policy. feat: add net48 Avalonia preview host and UIA smoke tests Pull the preview-host idea onto this branch, but refactor it to the branch's net48-only policy instead of the prototype branch's net8 shape. Adds a new net48 `FwAvaloniaPreviewHost` that discovers preview modules via assembly attributes in `FwAvalonia`, along with a runner script at `scripts/Agent/Run-AvaloniaPreview.ps1`. Wires the current lexical-edit POC into that host with: - `FwPreviewModuleAttribute` / `IFwPreviewDataProvider` - `PocPreviewWindow` + `PocPreviewDataProvider` - stable Avalonia automation IDs/names on the slice, text editors, labels, and morph-type chooser Adds native desktop automation tests in `FwAvaloniaPreviewHostTests` using `System.Windows.Automation` that launch the real preview-host exe and verify: - the main preview window and core controls expose stable automation IDs - the morph-type button supports InvokePattern and shows the popup list Updates `build.ps1 -BuildAvalonia` so the optional Avalonia lane also builds `FwAvaloniaPreviewHostTests` when `-BuildTests` is requested. Validated: - `dotnet build` `Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj` - `dotnet test` `Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj` passes repeatedly (2 UIA tests) - `scripts/Agent/Run-AvaloniaPreview.ps1 -BuildOnly` - the exact previously failing command now succeeds: `./build.ps1 -BuildAvalonia -Project` `"Src/Common/FwAvalonia/FwAvalonia.csproj" -NodeReuse $false` - `./build.ps1 -BuildAvalonia -BuildTests -Project` `"Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj"` `-SkipRestore -SkipDependencyCheck -NodeReuse $false` succeeds feat: add net48 Avalonia preview host and UIA smoke tests Pull the preview-host idea onto this branch, but refactor it to the branch's net48-only policy instead of the prototype branch's net8 shape. Adds a new net48 `FwAvaloniaPreviewHost` that discovers preview modules via assembly attributes in `FwAvalonia`, along with a runner script at `scripts/Agent/Run-AvaloniaPreview.ps1`. Wires the current lexical-edit POC into that host with: - `FwPreviewModuleAttribute` / `IFwPreviewDataProvider` - `PocPreviewWindow` + `PocPreviewDataProvider` - stable Avalonia automation IDs/names on the slice, text editors, labels, and morph-type chooser Adds native desktop automation tests in `FwAvaloniaPreviewHostTests` using `System.Windows.Automation` that launch the real preview-host exe and verify: - the main preview window and core controls expose stable automation IDs - the morph-type button supports InvokePattern and shows the popup list Updates `build.ps1 -BuildAvalonia` so the optional Avalonia lane also builds `FwAvaloniaPreviewHostTests` when `-BuildTests` is requested. Validated: - `dotnet build` `Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj` - the preview-host UIA test project passes repeatedly (2 UIA tests) - `scripts/Agent/Run-AvaloniaPreview.ps1 -BuildOnly` - the exact previously failing command now succeeds: `./build.ps1 -BuildAvalonia -Project` `"Src/Common/FwAvalonia/FwAvalonia.csproj" -NodeReuse $false` - `./build.ps1 -BuildAvalonia -BuildTests -Project` `FwAvaloniaPreviewHost.csproj` `-SkipRestore -SkipDependencyCheck -NodeReuse $false` succeeds feat: wire RecordEditView to the net48 Avalonia POC path Integrate the real app-side feature flag path for the lexical-edit Avalonia POC while keeping the WinForms DataTree path unchanged by default. Changes: - `RecordEditView` now uses `LexicalEditSurfaceResolver` and `LexicalEditSurfaceFactory` to select the visible surface. - When `FW_AVALONIA_LEXEDIT` is enabled, `RecordEditView` swaps the visible control to `PocWinFormsHostControl`, a WinForms wrapper around the Avalonia POC slice hosted through `WinFormsAvaloniaControlHost`. - `LexicalEditPocMapper` projects the current `LexEntry` into the detached DTO used by the Avalonia POC (lexeme form, morph type, first-sense gloss). - `xWorks` now references `FwAvalonia`; Avalonia package references are kept local to `FwAvalonia` so they do not pollute the `xWorks` restore graph. - The preview host and its UIA tests now use isolated `bin` outputs instead of the shared `Output/Debug` folder, avoiding runtime assembly-version clashes. Validated: - `dotnet build Src/xWorks/xWorks.csproj` - `dotnet build Src/xWorks/xWorksTests/xWorksTests.csproj` - `dotnet test Src/xWorks/xWorksTests/xWorksTests.csproj` with the `LexicalEditPocMapperTests` filter - `dotnet test` on the preview-host UIA test project passes - `./build.ps1 -BuildAvalonia -Project "Src/xWorks/xWorks.csproj"` with `-SkipRestore -SkipDependencyCheck -NodeReuse $false` - `./build.ps1 -BuildAvalonia -Project` `"Src/Common/FwAvalonia/FwAvalonia.csproj" -NodeReuse $false` feat: add persisted UIMode preference for lexical edit UI Introduce an app-wide user preference named `UIMode` with values `Legacy` and `New`. The preference is persisted in `FwApplicationSettings` and mirrored into the active `PropertyTable` at startup so existing colleague/property-change broadcasts can react to it immediately. `LexOptionsDlg` now exposes the setting on the Interface tab. When the user changes it and clicks OK: - the new mode is saved to app settings - the runtime `PropertyTable` mirror is updated - the current lexical edit view reloads without requiring an app restart `RecordEditView` now resolves its visible surface from the persisted UIMode (preference first, env var still available as a developer override), lazily initializes the hidden legacy DataTree and the embedded Avalonia host, and switches between them live when the UIMode property changes. Validated: - `dotnet build Src/Common/FwUtils/FwUtils.csproj` - `dotnet build Src/LexText/LexTextControls/LexTextControls.csproj` - `dotnet build Src/xWorks/xWorks.csproj` - `dotnet build Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj` - `dotnet test Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj` with `LexicalEditSurfaceResolverTests` - `dotnet test Src/xWorks/xWorksTests/xWorksTests.csproj` with `LexicalEditPocMapperTests` - `dotnet test` on the preview-host UIA test project - `./build.ps1 -BuildAvalonia -Project "Src/xWorks/xWorks.csproj"` with `-SkipRestore -SkipDependencyCheck -NodeReuse $false` --- .../navigation/screenshot-evidence.md | 3 + .../skills/smart-screenshot-capture/SKILL.md | 6 + .github/instructions/avalonia.instructions.md | 94 ++++ .../skills/fieldworks-avalonia-ui/SKILL.md | 30 ++ .../fieldworks-managed-netfx-review/SKILL.md | 27 + .../SKILL.md | 29 ++ .../SKILL.md | 42 ++ .../fieldworks-uia2-parity-testing/SKILL.md | 36 ++ .../SKILL.md | 28 + Directory.Packages.props | 31 +- .../avalonia-migration-approach-comparison.md | 309 +++++++++++ .../DataTreeDisposalCharacterizationTests.cs | 187 +++++++ .../DataTreeRenderTests.cs | 5 + .../DetailControlsTests/DataTreeTests.cs | 102 ++++ .../DataTreeUndoRedoCharacterizationTests.cs | 206 ++++++++ .../MorphTypeAtomicLauncherTests.cs | 241 +++++++++ .../RenderFailureArtifactBundlerTests.cs | 138 +++++ .../DetailControlsTests/SliceFactoryTests.cs | 22 + .../DetailControls/MorphTypeAtomicLauncher.cs | 154 +++--- Src/Common/FieldWorks/FieldWorks.cs | 2 + Src/Common/FwAvalonia/FwAvalonia.csproj | 45 ++ .../FwAvaloniaTests/FwAvaloniaTests.csproj | 43 ++ .../LexicalEditSurfaceResolverTests.cs | 128 +++++ .../FwAvaloniaTests/PocLexEntrySliceTests.cs | 128 +++++ .../FwAvalonia/FwAvaloniaTests/SeamTests.cs | 193 +++++++ .../FwAvaloniaTests/TestAppBuilder.cs | 24 + .../FwAvaloniaTests/ViewDefinitionTests.cs | 272 ++++++++++ .../FwAvalonia/LexicalEditSurfaceFactory.cs | 51 ++ .../FwAvalonia/LexicalEditSurfaceResolver.cs | 83 +++ .../FwAvalonia/Poc/MorphTypePopupChooser.cs | 90 ++++ .../FwAvalonia/Poc/MultiWsTextEditor.cs | 75 +++ Src/Common/FwAvalonia/Poc/PocApp.cs | 38 ++ Src/Common/FwAvalonia/Poc/PocDensity.cs | 33 ++ Src/Common/FwAvalonia/Poc/PocEditSession.cs | 60 +++ Src/Common/FwAvalonia/Poc/PocEntryDto.cs | 123 +++++ Src/Common/FwAvalonia/Poc/PocLexEntrySlice.cs | 88 ++++ .../FwAvalonia/Poc/PocPreviewDataProvider.cs | 44 ++ Src/Common/FwAvalonia/Poc/PocPreviewWindow.cs | 37 ++ .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 79 +++ .../Preview/AssemblyPreviewModules.cs | 8 + .../Preview/FwPreviewModuleAttribute.cs | 34 ++ .../Preview/IFwPreviewDataProvider.cs | 11 + Src/Common/FwAvalonia/Seams/ISeams.cs | 116 +++++ .../FwAvalonia/Seams/MorphTypeSwapLogic.cs | 118 +++++ .../FwAvalonia/Seams/RefreshCoordinator.cs | 47 ++ .../FwAvalonia/Seams/SeamImplementations.cs | 130 +++++ .../ViewDefinition/DictionaryPartResolver.cs | 104 ++++ .../ViewDefinition/EditorKindMap.cs | 95 ++++ .../ViewDefinition/IViewDefinitionImporter.cs | 40 ++ .../ViewDefinition/ViewDefinitionCacheKey.cs | 61 +++ .../ViewDefinition/ViewDefinitionCompiler.cs | 200 ++++++++ .../ViewDefinition/ViewDefinitionModel.cs | 258 ++++++++++ .../ViewDefinition/XmlLayoutImporter.cs | 280 ++++++++++ .../FwAvaloniaPreviewHost.csproj | 35 ++ .../FwAvaloniaPreviewHostTests.csproj | 32 ++ .../PreviewHostUiaTests.cs | 147 ++++++ .../FwAvaloniaPreviewHost/ModuleCatalog.cs | 94 ++++ .../FwAvaloniaPreviewHost/PreviewHostApp.cs | 104 ++++ .../PreviewHostLogging.cs | 71 +++ .../FwAvaloniaPreviewHost/PreviewOptions.cs | 45 ++ Src/Common/FwAvaloniaPreviewHost/Program.cs | 66 +++ Src/Common/FwUtils/FwApplicationSettings.cs | 6 + .../FwUtils/FwApplicationSettingsBase.cs | 1 + .../FwUtilsTests/TestFwApplicationSettings.cs | 2 + .../FwUtils/Properties/Settings.Designer.cs | 17 + .../FwUtils/Properties/Settings.settings | 3 + .../RenderTestInfrastructure.csproj | 3 + .../RenderFailureArtifactBundler.cs | 188 +++++++ .../ViewsInterfaces/ViewsInterfaces.csproj | 1 + Src/LexText/LexTextControls/LexOptionsDlg.cs | 119 +++++ .../LexTextControls/LexTextControls.resx | 16 + Src/xWorks/LexicalEditPocMapper.cs | 103 ++++ Src/xWorks/RecordEditView.cs | 179 +++++-- Src/xWorks/xWorks.csproj | 1 + Src/xWorks/xWorksTests/BulkEditBarTests.cs | 137 ++++- .../xWorksTests/LexicalEditPocMapperTests.cs | 50 ++ build.ps1 | 121 +++++ .../avalonia-migration-roadmap/.openspec.yaml | 2 + .../avalonia-migration-roadmap/design.md | 123 +++++ .../avalonia-migration-roadmap/proposal.md | 56 ++ .../specs/avalonia-migration-roadmap/spec.md | 47 ++ .../avalonia-migration-roadmap/tasks.md | 34 ++ .../.openspec.yaml | 2 + .../datatree-mental-model.md | 261 ++++++++++ .../datatree-model-view-separation/design.md | 149 ++++++ .../hybrid-alignment.md | 39 ++ .../proposal.md | 44 ++ .../coverage-wave2-test-matrix.md | 463 +++++++++++++++++ .../changes-from-test-before-refactor/spec.md | 159 ++++++ .../tests to fix coverage gaps.md | 70 +++ .../datatree-characterization-tests/spec.md | 73 +++ .../test-plan-datatree.md | 483 ++++++++++++++++++ .../test-plan-forms-future.md | 241 +++++++++ .../test-plan-forms.md | 239 +++++++++ .../test-plan-harness.md | 302 +++++++++++ .../test-plan-slice.md | 379 ++++++++++++++ .../testing-approach-2.md | 339 ++++++++++++ .../specs/datatree-model/spec.md | 118 +++++ .../specs/datatree-partial-split/spec.md | 39 ++ .../datatree-model-view-separation/tasks.md | 156 ++++++ .../.openspec.yaml | 2 + .../design.md | 89 ++++ .../proposal.md | 46 ++ .../spec.md | 180 +++++++ .../tasks.md | 77 +++ .../.openspec.yaml | 2 + .../architecture-diagrams.md | 370 ++++++++++++++ .../avalonia-command-focus.md | 57 +++ .../avalonia-edit-sessions.md | 55 ++ .../avalonia-lifetime.md | 68 +++ .../avalonia-ui-scheduler.md | 63 +++ .../avalonia-undo-redo.md | 53 ++ .../avalonia-validation.md | 63 +++ .../coverage-map.md | 117 +++++ .../lexical-edit-avalonia-migration/design.md | 221 ++++++++ .../graphite-decommissioning.md | 68 +++ .../migration-map.md | 15 + .../override-fixtures.md | 67 +++ .../phase2-execution-evidence.md | 113 ++++ .../proposal.md | 57 +++ .../region-manifest.md | 121 +++++ .../seam-recommendations.md | 153 ++++++ .../interop/native-boundary/spec.md | 41 ++ .../testing/test-strategy/spec.md | 21 + .../ui-framework/views-rendering/spec.md | 47 ++ .../ui-framework/winforms-patterns/spec.md | 21 + .../specs/avalonia-command-focus/spec.md | 25 + .../specs/avalonia-edit-sessions/spec.md | 26 + .../specs/avalonia-lifetime/spec.md | 25 + .../specs/avalonia-ui-scheduler/spec.md | 25 + .../specs/avalonia-undo-redo/spec.md | 26 + .../specs/avalonia-validation/spec.md | 25 + .../lexical-edit-avalonia-migration/spec.md | 135 +++++ .../lexical-edit-font-decommissioning/spec.md | 63 +++ .../lexical-edit-parity-automation/spec.md | 109 ++++ .../lexical-edit-view-definition/spec.md | 90 ++++ .../lexical-edit-avalonia-migration/tasks.md | 109 ++++ .../view-inventory.md | 68 +++ .../.openspec.yaml | 2 + .../lexical-edit-avalonia-poc-spike/design.md | 146 ++++++ .../proposal.md | 69 +++ .../lexical-edit-avalonia-poc-spike/spec.md | 81 +++ .../spike-evidence.md | 100 ++++ .../lexical-edit-avalonia-poc-spike/tasks.md | 87 ++++ .../test-plan.md | 78 +++ scripts/Agent/Run-AvaloniaPreview.ps1 | 84 +++ 146 files changed, 13646 insertions(+), 101 deletions(-) create mode 100644 .github/instructions/avalonia.instructions.md create mode 100644 .github/skills/fieldworks-avalonia-ui/SKILL.md create mode 100644 .github/skills/fieldworks-managed-netfx-review/SKILL.md create mode 100644 .github/skills/fieldworks-migration-scope-review/SKILL.md create mode 100644 .github/skills/fieldworks-semantic-render-parity/SKILL.md create mode 100644 .github/skills/fieldworks-uia2-parity-testing/SKILL.md create mode 100644 .github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md create mode 100644 Docs/avalonia-migration-approach-comparison.md create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeDisposalCharacterizationTests.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeUndoRedoCharacterizationTests.cs create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/RenderFailureArtifactBundlerTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvalonia.csproj create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs create mode 100644 Src/Common/FwAvalonia/LexicalEditSurfaceFactory.cs create mode 100644 Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs create mode 100644 Src/Common/FwAvalonia/Poc/MorphTypePopupChooser.cs create mode 100644 Src/Common/FwAvalonia/Poc/MultiWsTextEditor.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocApp.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocDensity.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocEditSession.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocEntryDto.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocLexEntrySlice.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocPreviewDataProvider.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocPreviewWindow.cs create mode 100644 Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs create mode 100644 Src/Common/FwAvalonia/Preview/AssemblyPreviewModules.cs create mode 100644 Src/Common/FwAvalonia/Preview/FwPreviewModuleAttribute.cs create mode 100644 Src/Common/FwAvalonia/Preview/IFwPreviewDataProvider.cs create mode 100644 Src/Common/FwAvalonia/Seams/ISeams.cs create mode 100644 Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs create mode 100644 Src/Common/FwAvalonia/Seams/RefreshCoordinator.cs create mode 100644 Src/Common/FwAvalonia/Seams/SeamImplementations.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/DictionaryPartResolver.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/IViewDefinitionImporter.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCacheKey.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCompiler.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj create mode 100644 Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj create mode 100644 Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/ModuleCatalog.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/PreviewHostLogging.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/PreviewOptions.cs create mode 100644 Src/Common/FwAvaloniaPreviewHost/Program.cs create mode 100644 Src/Common/RenderVerification/RenderFailureArtifactBundler.cs create mode 100644 Src/xWorks/LexicalEditPocMapper.cs create mode 100644 Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs create mode 100644 openspec/changes/avalonia-migration-roadmap/.openspec.yaml create mode 100644 openspec/changes/avalonia-migration-roadmap/design.md create mode 100644 openspec/changes/avalonia-migration-roadmap/proposal.md create mode 100644 openspec/changes/avalonia-migration-roadmap/specs/avalonia-migration-roadmap/spec.md create mode 100644 openspec/changes/avalonia-migration-roadmap/tasks.md create mode 100644 openspec/changes/datatree-model-view-separation/.openspec.yaml create mode 100644 openspec/changes/datatree-model-view-separation/datatree-mental-model.md create mode 100644 openspec/changes/datatree-model-view-separation/design.md create mode 100644 openspec/changes/datatree-model-view-separation/hybrid-alignment.md create mode 100644 openspec/changes/datatree-model-view-separation/proposal.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md create mode 100644 openspec/changes/datatree-model-view-separation/tasks.md create mode 100644 openspec/changes/fieldworks-avalonia-shell-migration/.openspec.yaml create mode 100644 openspec/changes/fieldworks-avalonia-shell-migration/design.md create mode 100644 openspec/changes/fieldworks-avalonia-shell-migration/proposal.md create mode 100644 openspec/changes/fieldworks-avalonia-shell-migration/specs/fieldworks-avalonia-shell-migration/spec.md create mode 100644 openspec/changes/fieldworks-avalonia-shell-migration/tasks.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/.openspec.yaml create mode 100644 openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-command-focus.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-edit-sessions.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-lifetime.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-ui-scheduler.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-undo-redo.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/avalonia-validation.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/coverage-map.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/design.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/migration-map.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/override-fixtures.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/proposal.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/region-manifest.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/architecture/interop/native-boundary/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/architecture/testing/test-strategy/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/views-rendering/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/winforms-patterns/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-command-focus/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-edit-sessions/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-lifetime/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-ui-scheduler/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-undo-redo/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-validation/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-view-definition/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/tasks.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/view-inventory.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/.openspec.yaml create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/design.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/proposal.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/specs/lexical-edit-avalonia-poc-spike/spec.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/tasks.md create mode 100644 openspec/changes/lexical-edit-avalonia-poc-spike/test-plan.md create mode 100644 scripts/Agent/Run-AvaloniaPreview.ps1 diff --git a/.claude/skills/fieldworks-winapp/navigation/screenshot-evidence.md b/.claude/skills/fieldworks-winapp/navigation/screenshot-evidence.md index ce8ea59202..cc451aa6c9 100644 --- a/.claude/skills/fieldworks-winapp/navigation/screenshot-evidence.md +++ b/.claude/skills/fieldworks-winapp/navigation/screenshot-evidence.md @@ -53,8 +53,11 @@ Use names that tell the story in sorted order: - `02-before-.png` - `03-after-.png` - `04-after-.png` +- Path 3 parity bundle: `01-winforms-.png`, `02-avalonia-.png`, `03-diff-.png` - `sequence--001.png`, `sequence--002.png`, ... +When the task is migration parity, capture matched WinForms and Avalonia framing for the same scenario id and store them under `openspec/changes//evidence/parity//` so the visual lane lines up with the semantic snapshot and the workflow/accessibility evidence. + ## Expected Signals - Screenshots should show FieldWorks, not VS Code or another foreground app. diff --git a/.claude/skills/smart-screenshot-capture/SKILL.md b/.claude/skills/smart-screenshot-capture/SKILL.md index 3786b87b60..b616f1f6c3 100644 --- a/.claude/skills/smart-screenshot-capture/SKILL.md +++ b/.claude/skills/smart-screenshot-capture/SKILL.md @@ -62,6 +62,7 @@ For this repository, default to: - transient evidence: `Output/ManualEvidence//` - OpenSpec review evidence: `openspec/changes//evidence/manual-winapp/` +- Path 3 parity bundle evidence: `openspec/changes//evidence/parity//` - ad hoc screenshots: `Output/ManualEvidence/screenshots/` Create the folder if needed. Do not put scratch screenshots in committed @@ -73,6 +74,7 @@ Use sorted, descriptive names: - single capture: `-.png` - before/after: `01-before-.png`, `02-after-.png` +- Path 3 parity: `01-winforms-.png`, `02-avalonia-.png`, `03-diff-.png` - sequence: `step-01-.png`, `step-02-.png` - app tour: `-.png` - temporary fallback: `screenshot-YYYY-MM-DD-HHMMSS.png` @@ -121,6 +123,10 @@ Use multiple captures when one image cannot tell the story: screenshot; - comparison: capture both images, then run a screenshot diff when available. +For migration parity bundles, keep framing, DPI, zoom, and window size matched across WinForms and Avalonia captures whenever density, wrapping, or spacing is under review. + +For a Path 3 parity bundle, pair screenshots with the matching semantic snapshot and workflow/accessibility evidence for the same scenario id; a screenshot pair alone is not a full parity claim. + For sequences, keep the same target, window size, and framing across captures unless the task is specifically about responsive or layout behavior. diff --git a/.github/instructions/avalonia.instructions.md b/.github/instructions/avalonia.instructions.md new file mode 100644 index 0000000000..e6b2590908 --- /dev/null +++ b/.github/instructions/avalonia.instructions.md @@ -0,0 +1,94 @@ +--- +applyTo: "**/*" +name: "avalonia.instructions" +description: "Guidance for FieldWorks Avalonia modules and the shared Preview Host" +--- + +# Avalonia Modules (FieldWorks) + +## Purpose & Scope +- Provide a consistent way to **create, build, test, and preview** Avalonia UI modules in FieldWorks. +- Applies to the Advanced Entry Avalonia work under `specs/010-advanced-entry-view/` and future Avalonia modules. + +## Key Rules + +### Build & test (always use repo scripts) +- Build the repo using the traversal script: + - `./build.ps1` +- Run tests using the repo test runner: + - `./test.ps1` +- Do **not** rely on `dotnet build` for repo-wide builds; FieldWorks build targets include tasks that require full Visual Studio/MSBuild. + +### Project locations & naming +- Feature modules live under `Src//.Avalonia/`. + - Example: `Src/LexText/AdvancedEntry.Avalonia/` +- Shared Avalonia utilities live under `Src/Common/FwAvalonia/`. +- Preview tooling lives under `Src/Common/FwAvaloniaPreviewHost/`. + +### Solution + traversal integration (required) +For every new Avalonia module or tool: +- Add the project(s) to the traversal build so `./build.ps1` and `./test.ps1` naturally cover them: + - `FieldWorks.proj` +- Add the project(s) to the solution so developers can open/build/debug in Visual Studio: + - `FieldWorks.sln` + +### Logging (use FieldWorks diagnostics) +- Module logging must route through the existing FieldWorks diagnostics pipeline (`System.Diagnostics`, `TraceSwitch`, `EnvVarTraceListener`). +- Add a `TraceSwitch` entry for each module/component in the dev diagnostics config: + - `Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config` + +### Preview Host diagnostics (log file) +- The Preview Host writes startup errors and trace output to a log file next to the executable: + - `Output//FieldWorks.trace.log` (e.g. `Output/Debug/FieldWorks.trace.log`) +- To override the log path, set environment variable `FW_PREVIEW_TRACE_LOG` to a full file path. + +### Preview Host (fast UI iteration) +To preview UI without launching the full FieldWorks app, use the shared Preview Host. + +**How modules opt-in** +- Register the module using an assembly-level attribute: + - `FwPreviewModuleAttribute` in `Src/Common/FwAvalonia/Preview/` +- Provide an optional data provider implementing: + - `IFwPreviewDataProvider` + +**Run the preview** +- Use the agent script (build + run): + - `./scripts/Agent/Run-AvaloniaPreview.ps1 -Module advanced-entry -Data sample` +- Supported `-Data` modes depend on the module’s data provider; the current convention is: + - `empty` (minimal/default DataContext) + - `sample` (representative sample data) + +## Expected Structure (current) + +- Module: + - `Src/LexText/AdvancedEntry.Avalonia/` +- Shared utilities/contracts: + - `Src/Common/FwAvalonia/` + - `Diagnostics/` (logging shim) + - `Preview/` (module registration + data provider contracts) +- Preview host executable: + - `Src/Common/FwAvaloniaPreviewHost/` +- Launcher script: + - `scripts/Agent/Run-AvaloniaPreview.ps1` + +## Examples + +### Build everything (recommended) +```powershell +./build.ps1 +``` + +### Run tests +```powershell +./test.ps1 +``` + +### Preview the Advanced Entry module +```powershell +./scripts/Agent/Run-AvaloniaPreview.ps1 -Module advanced-entry -Data sample +``` + +## Notes & Constraints +- Avalonia modules should remain **detached from LCModel** for preview scenarios (use DTO/view-model sample data) to keep the Preview Host lightweight. +- Keep all user-visible strings localizable (use `.resx` patterns where applicable; do not hardcode translatable UI text). +- Treat any input that crosses managed/native boundaries as untrusted; sanitize and validate per repo security guidance. diff --git a/.github/skills/fieldworks-avalonia-ui/SKILL.md b/.github/skills/fieldworks-avalonia-ui/SKILL.md new file mode 100644 index 0000000000..a83a15bc89 --- /dev/null +++ b/.github/skills/fieldworks-avalonia-ui/SKILL.md @@ -0,0 +1,30 @@ +--- +name: fieldworks-avalonia-ui +description: Use when creating, reviewing, or fixing Avalonia UI modules in FieldWorks, especially XAML, MVVM, preview-host, localization, accessibility, or net8 Avalonia test changes. +--- + +# FieldWorks Avalonia UI + +## Use This For +- Avalonia XAML, view models, commands, lifetimes, dispatching, and resource/style changes. +- New or changed projects under `Src/**/**/*.Avalonia/`, `Src/Common/FwAvalonia/`, and `Src/Common/FwAvaloniaPreviewHost/`. +- Preview Host module registration, sample data providers, and UI diagnostics. + +## Required Checks +- Use current Avalonia docs for uncertain APIs; do not guess dispatcher, headless, automation, or binding behavior. +- Keep product UI strings localizable; prototype hardcoded strings must be called out as gaps. +- Stable accessibility identity belongs on user-facing controls via Avalonia automation properties. +- UI work should stay in bindings/view models where practical; avoid logic-heavy code-behind. +- Keep module preview data lightweight unless the change explicitly opts into LCModel/project data. +- Preserve repo build/test entry points: `./build.ps1` and `./test.ps1`. +- For Path 3 visual parity, remember the official Avalonia behavior: headless tests can simulate keyboard/mouse/text input on `Window`, `Dispatcher.UIThread.RunJobs()` flushes deferred UI work, and visual regression capture requires Skia + `UseHeadlessDrawing=false` with `CaptureRenderedFrame()`. +- Stamp stable `AutomationProperties.Name` and `AutomationProperties.AutomationId` on user-facing controls that participate in parity bundles so the UIA/accessibility lane can identify them reliably. + +## Review Red Flags +- A Common project directly references a feature module without an explicit architecture decision. +- Preview-only code is launched from product UI without a feature gate and real-project behavior story. +- Sleep-based or timing-sensitive UI tests. +- Claims of accessibility, localization, IME, or keyboard parity without executable evidence. + +## Handoff +Report exact Avalonia docs consulted, tests run, remaining prototype gaps, and whether the change is product-facing or preview-only. For Path 3 work, say whether the visual evidence is control-level headless capture or live desktop capture, and which accessibility identities were assigned via `AutomationProperties`. \ No newline at end of file diff --git a/.github/skills/fieldworks-managed-netfx-review/SKILL.md b/.github/skills/fieldworks-managed-netfx-review/SKILL.md new file mode 100644 index 0000000000..a74d3b4447 --- /dev/null +++ b/.github/skills/fieldworks-managed-netfx-review/SKILL.md @@ -0,0 +1,27 @@ +--- +name: fieldworks-managed-netfx-review +description: Use when reviewing or changing FieldWorks managed C# projects that cross .NET Framework 4.8, C# 7.3, SDK-style net8, tests, or project-file boundaries. +--- + +# FieldWorks Managed NetFx Review + +## Compatibility Split +- Legacy product code is .NET Framework 4.8 and C# 7.3 unless a project explicitly targets modern .NET. +- New Avalonia modules may target `net8.0-windows`; do not leak C# 8+ syntax or net8-only APIs into net48 projects. +- Legacy `.csproj` files require explicit source inclusion; SDK-style projects have different defaults. + +## Required Checks +- User-visible strings use `.resx` patterns where product-facing. +- UI and async code marshals to the correct UI thread and does not use sync-over-async. +- Disposable WinForms/GDI/LCModel/test resources are owned and disposed deterministically. +- Test discovery changes must be validated across both net48 and net8 test assemblies. +- Use repo scripts for evidence: `./build.ps1` and `./test.ps1`. + +## Review Red Flags +- Nullable annotations, records, file-scoped namespaces, switch expressions, or `using var` in net48/C# 7.3 projects. +- Broad project/test-runner changes justified only by one local test passing. +- Hardcoded Debug paths or absolute repo assumptions in tests. +- Skipped tests used as evidence of covered behavior. + +## Handoff +Report target frameworks touched, project-file implications, test commands/results, and any remaining compatibility risks. \ No newline at end of file diff --git a/.github/skills/fieldworks-migration-scope-review/SKILL.md b/.github/skills/fieldworks-migration-scope-review/SKILL.md new file mode 100644 index 0000000000..2c18dafefc --- /dev/null +++ b/.github/skills/fieldworks-migration-scope-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: fieldworks-migration-scope-review +description: Use when reviewing large FieldWorks migration PRs, OpenSpec changes, foundational branches, scope splits, draft PR readiness, or evidence claims. +--- + +# FieldWorks Migration Scope Review + +## Review Posture +Treat foundational migration PRs as architecture and evidence packages. The main question is whether reviewers can trust the scope, claims, and validation boundary. + +## Required Checks +- Compare PR title/body/tasks against the actual diff. +- Classify files as plan/spec, characterization test, infrastructure, prototype, product behavior, or unrelated change. +- Verify checked tasks match evidence language; downgrade claims when evidence says substitute, placeholder, skipped, future, or partial. +- Confirm validation gates are explicit: OpenSpec validation, targeted tests, `./build.ps1`, and `CI: Full local check` when ready. + +## Split Triggers +- Product-visible behavior appears in a planning/test PR. +- Common infrastructure directly depends on the first feature module without an explicit decision. +- Test-runner/build graph changes are mixed with UI migration work. +- Unrelated behavior changes require their own review context. + +## Review Red Flags +- A draft PR is so broad that each reviewer must reverse-engineer intent. +- Evidence is stale after rebase or differs from visible CI state. +- A prototype is wired as if it were a product feature. + +## Handoff +Lead with blockers, then list what to remove, split, reword, or validate before review. \ No newline at end of file diff --git a/.github/skills/fieldworks-semantic-render-parity/SKILL.md b/.github/skills/fieldworks-semantic-render-parity/SKILL.md new file mode 100644 index 0000000000..cba4c2b18f --- /dev/null +++ b/.github/skills/fieldworks-semantic-render-parity/SKILL.md @@ -0,0 +1,42 @@ +--- +name: fieldworks-semantic-render-parity +description: Use when capturing or reviewing FieldWorks semantic snapshots, render baselines, layout parity, failure artifacts, XML view definitions, or Avalonia presentation IR. +--- + +# FieldWorks Semantic Render Parity + +## Snapshot Discipline +Semantic snapshots should preserve behaviorally meaningful identity and omit incidental layout noise. + +## Include +- Stable node ID and source layout/part identity. +- Object/class binding, field/flid binding, editor kind, writing-system metadata, visibility, ghost state, expansion, focus order, localization key, and accessibility identity. +- Unsupported construct diagnostics with enough path context to fix the source layout. + +## Exclude Or Normalize +- Pixel bounds, transient generated names, timestamps, machine paths, culture-dependent ordering, and realized-control counts unless the test explicitly owns them. + +## Render Evidence +- Pixel/render tests need deterministic fixtures, clear thresholds, and failure artifacts that reviewers can inspect. +- A semantic snapshot is not a substitute for visual/render parity when typography, density, wrapping, or native rendering seams are under review. + +## Path 3 Bundle +For migration-quality visual fidelity, prefer a triangulated bundle instead of a single artifact lane: + +- semantic snapshot, +- visual evidence for legacy WinForms and Avalonia, +- diff/variance artifact, +- workflow/accessibility evidence, +- one failure summary that classifies the broken lane. + +Use the semantic snapshot as the anchor. Visual variance should be interpreted against stable binding/focus/accessibility identity, not in isolation. + +Control-level Avalonia visual evidence may come from Avalonia.Headless rendered frames when the scenario is explicitly control-scoped. Desktop workflow/accessibility claims still need live-window evidence. + +## Review Red Flags +- Placeholder metadata is presented as real binding or writing-system parity. +- Snapshot tests update large JSON blobs without a small behavioral explanation. +- Cache invalidation tests depend on sleeps or filesystem timestamp luck. + +## Handoff +State whether evidence is semantic, visual, accessibility/workflow, or performance parity, and identify remaining unproven axes. When a Path 3 bundle is used, name each artifact and which lane it proves. \ No newline at end of file diff --git a/.github/skills/fieldworks-uia2-parity-testing/SKILL.md b/.github/skills/fieldworks-uia2-parity-testing/SKILL.md new file mode 100644 index 0000000000..b8bd63e527 --- /dev/null +++ b/.github/skills/fieldworks-uia2-parity-testing/SKILL.md @@ -0,0 +1,36 @@ +--- +name: fieldworks-uia2-parity-testing +description: Use when designing or reviewing FieldWorks UI automation, UIA2, FlaUI, Appium, WinAppDriver, Avalonia.Headless, accessibility, keyboard, focus, or IME parity tests. +--- + +# FieldWorks UIA2 Parity Testing + +## Lane Separation +- Avalonia.Headless is for fast in-process control, layout, view-model, binding, and input tests. +- UIA2/FlaUI/Appium/WinAppDriver tests require realized desktop windows and validate native accessibility trees, focus, invoke patterns, and product integration. +- Do not call a headless smoke test a UIA2 baseline. + +## Path 3 Role +In a Path 3 parity bundle, UIA2/FlaUI/Appium contributes the workflow/accessibility lane only: + +- launcher/chooser reachability, +- focus movement and focus return, +- invoke/cancel/accept paths, +- native automation tree identity, +- shell-level keyboard behavior. + +It does not replace semantic snapshots or visual/render evidence. A desktop automation result should be reported alongside the semantic and visual artifacts for the same scenario id. + +## Required Evidence +- Stable automation IDs or accessible names for controls under test. +- Explicit coverage of focus movement, invoke/click path, popup/chooser reachability, keyboard shortcuts, and failure artifacts. +- Clear CI lane: headless can run broadly; desktop automation needs an interactive Windows desktop or a configured automation host. + +## Review Red Flags +- “Runs in the background” used for UIA2/Appium without explaining the required desktop/session. +- Tests assert implementation internals instead of user-observable accessibility behavior. +- Automation selectors rely on localized labels when stable IDs are available or required. +- IME coverage is claimed without a real text editor/control surface and input-method evidence. + +## Handoff +Classify each test as headless, native desktop automation, or smoke substitute, and state what parity claim it can and cannot support. When used in a Path 3 bundle, say explicitly which workflow/accessibility assertions the desktop lane proved and which still need another lane. \ No newline at end of file diff --git a/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md b/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md new file mode 100644 index 0000000000..3b03dd4c30 --- /dev/null +++ b/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md @@ -0,0 +1,28 @@ +--- +name: fieldworks-winforms-to-avalonia-migration +description: Use when planning, reviewing, or implementing FieldWorks WinForms/xWorks/DataTree/XMLViews migration paths to Avalonia, including seam extraction and parity coverage. +--- + +# FieldWorks WinForms To Avalonia Migration + +## Core Rule +Migrate by proving behavior first, extracting seams second, and introducing Avalonia controls only after legacy behavior has executable parity evidence. + +## Required Baselines +- Entry points: `RecordEditView`, `DataTree`, `SliceFactory`, XMLViews browse/table views, launchers, popup choosers, and command/listener wiring. +- Semantics: object/class binding, flid/field binding, labels, visibility, ghost state, expansion, focus order, writing-system metadata, accessibility identity, and localization keys. +- User workflows: create/edit/save/cancel, chooser OK/cancel, undo/redo, refresh/postponed `PropChanged`, keyboard focus restoration, and disposal/unsubscribe. + +## Architecture Checks +- Keep WinForms Designer-safe code isolated from extracted logic. +- Extract humble objects/services for modal decisions and data-loss classifiers before replacing controls. +- Put an editor registry or adapter boundary in front of legacy `SliceFactory` behavior before mixing legacy and Avalonia editors. +- Treat product command wiring as product behavior, not preview scaffolding. + +## Review Red Flags +- A PR mixes plans, tests, infrastructure, product UI wiring, and unrelated behavior changes. +- Task checkboxes claim UIA2/IME/accessibility/localization parity while evidence says substitute, placeholder, skipped, or future work. +- Avalonia preview data modifies or pretends to modify real project data without a real edit-session contract. + +## Handoff +State what is legacy baseline, what is extracted seam, what is Avalonia prototype, and what remains outside parity. \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index ad9cc434ba..8147d0fd71 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -157,8 +157,14 @@ ============================================================= --> - - + + + @@ -168,4 +174,25 @@ + + + + + + + + + + diff --git a/Docs/avalonia-migration-approach-comparison.md b/Docs/avalonia-migration-approach-comparison.md new file mode 100644 index 0000000000..d697423942 --- /dev/null +++ b/Docs/avalonia-migration-approach-comparison.md @@ -0,0 +1,309 @@ +# Avalonia Migration: Comparison of the Two Plans + Recommended Paths + +**Date:** 2026-06-05 +**Author:** Review synthesis (subagent-assisted) +**Goal under evaluation:** Move the whole FieldWorks UI to Avalonia, starting with the main +Lexical Edit view (after a small proof-of-concept), preserving *visual functional fidelity and +density* (not pixel-perfect), with the new path behind a **feature flag** so the app can run either +Avalonia or the legacy WinForms controls. + +This document compares the two existing planning sets, shows how their **scope and detail differ**, +and proposes **three ways to proceed** with pros/cons and a recommendation. + +--- + +## 1. The two plans at a glance + +| | **Plan A — "datatree-model-view-separation"** (older; this branch `datatree-model-view`) | **Plan B — "lexical-edit-avalonia-migration" + "fieldworks-avalonia-shell-migration"** (newer; branch `010-advanced-entry-view-phase-1-2`) | +|---|---|---| +| **Core idea** | Refactor `DataTree.cs` (4.7k-line God Class) into a UI-agnostic **model** + a WinForms **view** behind `IDataTreeView`, so an Avalonia view can be added later. | **Refactor dependency-inverted *seams* first**, introduce a **typed view-definition IR**, build owned Avalonia editors, decommission Graphite + native Views, then replace the whole shell. | +| **End state** | `DataTreeModel` + `SliceSpec[]` + `IDataTreeView`; WinForms still the only renderer. Avalonia view is explicitly **out of scope / future**. | Avalonia is the **default host** for Lexical Edit (Phase 1), then the **whole app shell** (Phase 2). No WinForms/native in the default render path. | +| **Reaches working Avalonia?** | No — stops at the abstraction boundary. | Yes — that is the explicit objective, via a first editable Avalonia slice → full Lexical Edit → shell. | +| **Graphite / native Views removal** | Not addressed. | Explicit completion gate; inventory + classify + replace (OpenType/HarfBuzz) or block with diagnostics. | +| **Feature-flag / dual-run** | Not designed (inferred only). | Designed: **two-adapter pattern** (legacy WinForms adapter vs Avalonia adapter) behind a flag/preview host. | +| **Testing** | Characterization tests + unit tests for extracted collaborators; manual smoke. | Layered: unit/integration + **semantic parity snapshots** + UIA2 legacy smoke + Avalonia.Headless + full-app smoke + perf budgets. | +| **Effort** | ~9–16 days (Phases 0–3). | ~40–50 weeks end-to-end (both changes). | +| **Status** | `IDataTreePainter` exists; characterization tests partially done; no model/view classes yet. | Phase 1 done; Phase 2 ~85% (93 tests, semantic baseline capture, seam docs frozen); net8 Avalonia prototype split to branch `010-advanced-entry-preview-prototype`; Phase 3+ not started. | +| **Granularity** | One focused change, 4 phases. | Two changes, ~15+ phases, 6 frozen "seam recommendation" capability specs, region manifests, coverage maps. | + +**One-line summary:** Plan A is a *narrow, low-risk enabler* (clean up DataTree so Avalonia is +possible). Plan B is a *full migration program* (actually get to Avalonia, app-wide, with the hard +problems — Graphite, native Views, shell, parity — scoped in). + +--- + +## 2. Plan A architecture & scope (older) + +```mermaid +flowchart TB + Host["RecordEditView / DTMenuHandler
(public facade preserved)"]:::host + subgraph Model["DataTreeModel — UI-agnostic (no System.Windows.Forms)"] + LB["SliceLayoutBuilder
XML → specs (~1000 lines)"]:::model + SH["ShowHiddenFieldsManager"]:::model + NAV["DataTreeNavigator
(focus/goto state)"]:::model + end + SPEC["SliceSpec[]
framework-neutral descriptors
label, indent, editor type, flid, ws…"]:::contract + IVIEW["IDataTreeView (abstraction)"]:::contract + WF["DataTree : UserControl (WinForms view)
materialize specs → Slice controls
layout, paint, splitter, focus,
ObjSeqHashMap reuse"]:::legacy + AV["AvaloniaDataTreeView : IDataTreeView
(explicitly FUTURE / out of scope)"]:::future + Native["Native Views / Graphite
(untouched)"]:::untouched + + Host --> Model --> SPEC --> IVIEW + IVIEW --> WF + IVIEW -. future .-> AV + WF --> Native + + classDef host fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef contract fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef untouched fill:#f1f5f9,stroke:#94a3b8,color:#0f172a; +``` + +```mermaid +flowchart LR + P0["Phase 0
Characterization tests
1–2 d · low risk"]:::p + P1["Phase 1
Partial-class split
1 d · very low risk"]:::p + P2["Phase 2
Extract collaborators
3–5 d · low–med risk"]:::p + P3["Phase 3
Model/View split
(SliceSpec, IDataTreeView)
5–8 d · medium risk"]:::p + STOP["STOPS HERE
Avalonia view = separate future change"]:::stop + P0 --> P1 --> P2 --> P3 --> STOP + classDef p fill:#eef2ff,stroke:#6366f1,color:#1e1b4b; + classDef stop fill:#fee2e2,stroke:#b91c1c,color:#450a0a; +``` + +**Scope:** managed C# only, centered on `DataTree.cs` / `Slice.cs` / `SliceFactory.cs` in +`Src/Common/Controls/DetailControls/`, with minimal touch of `RecordEditView` and `DTMenuHandler`. +**Out of scope:** Avalonia itself, native C++, Graphite, XML format changes, dual-run wiring. + +--- + +## 3. Plan B architecture & scope (newer) + +```mermaid +flowchart TB + XML["XML Parts/Layout
(customer overrides)"]:::legacy + IMP["XML Import adapter
(transitional)"]:::adapter + IR["Typed Presentation IR
editor descriptors, bindings,
visibility, ghost, focus, ws hints"]:::model + LCM["LCModel (data + transactions)"]:::model + + subgraph Ports["Dependency-inverted seams (framework-neutral)"] + R["ILexicalRefreshCoordinator"]:::port + VD["IViewDefinition* (source/importer/compiler/cache)"]:::port + ER["ILexicalEditorRegistry"]:::port + ES["IEditSession (fenced txn, undo/redo)"]:::port + CMD["IXCoreCommandBridge / IPropertyStateStore"]:::port + SCH["IUiScheduler / IRegionLifetime"]:::port + end + + subgraph Legacy["Legacy adapter (default during migration)"] + WF["DataTree / Slice / SliceFactory
RootSite → Native Views"]:::legacy + G["Graphite / Gecko / PDF"]:::decom + end + subgraph Avalonia["Avalonia adapter (becomes default per region)"] + ED["FieldWorks-owned Avalonia editors"]:::future + TT["Avalonia table/tree renderer"]:::future + HOST["Avalonia shell (Phase 2)"]:::future + end + + XML --> IMP --> IR + VD --> IR + LCM --> IR + IR --> ER + ER --> WF + ER --> ED + ES --> ED + R --> WF + R --> HOST + CMD --> HOST + SCH --> HOST + ED --> TT --> HOST + WF --> G + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef adapter fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray:6 4,color:#450a0a; +``` + +```mermaid +flowchart LR + subgraph Phase1["Change 1 — Lexical Edit (regional)"] + L1["P1 Baseline & spec audit
2–3 wk · ✅ done"]:::done + L2["P2 Tests before refactor
3–4 wk · ~85% ✅"]:::done + L3["P3 Refactor seams first
4–5 wk · med"]:::p + L4["P4–6 Avalonia control slices
6–8 wk · med/high"]:::p + L5["P7 Tables + full Lexical Edit
6–8 wk · high"]:::p + L6["P9 XML retirement
2–3 wk · med"]:::p + end + subgraph Phase2["Change 2 — Shell (app-wide)"] + S1["Contracts + typed shell
+ command routing"]:::p + S2["Avalonia shell skeleton
+ navigation/menus/dialogs"]:::p + S3["Main-screen migration
+ default switch + installer"]:::p + end + L1-->L2-->L3-->L4-->L5-->L6 + L5 -. gates .-> S1 --> S2 --> S3 + classDef p fill:#eef2ff,stroke:#6366f1,color:#1e1b4b; + classDef done fill:#dcfce7,stroke:#16a34a,color:#052e16; +``` + +**Scope:** managed seams + Avalonia editors + Graphite/native-Views decommissioning + (Change 2) +the entire shell, navigation, menus, dialogs, startup/shutdown, installer. +**Out of scope:** LCModel/schema rewrite, deleting linguistics services (XAmple, parsers, ICU), +one-shot migration of non-Lexical-Edit screens before shell seams exist. + +--- + +## 4. How the scope & detail differ + +```mermaid +flowchart LR + subgraph A["Plan A scope"] + A1["DataTree God-class
model/view split"]:::a + end + subgraph Shared["Conceptual overlap"] + O1["UI-agnostic descriptors
SliceSpec ≈ Typed IR node"]:::o + O2["Editor selection seam
IDataTreeView ≈ ILexicalEditorRegistry"]:::o + O3["Characterization tests
before refactor"]:::o + end + subgraph B["Plan B scope (superset)"] + B1["Typed view-definition IR + XML import"]:::b + B2["Edit sessions / undo-redo / validation seams"]:::b + B3["Graphite + native Views decommissioning"]:::b + B4["Avalonia editors + tables + parity automation"]:::b + B5["Feature-flag dual-run (2 adapters)"]:::b + B6["Whole-app shell migration"]:::b + end + A1 --- O1 --- B1 + A1 --- O2 --- B2 + O3 --- B4 + classDef a fill:#fff7ed,stroke:#f97316,color:#431407; + classDef b fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef o fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; +``` + +Key differences: + +1. **Ambition.** Plan A *enables* Avalonia; Plan B *delivers* Avalonia (and removes the things that + block it — Graphite, native Views, the WinForms shell). +2. **The hard problems.** Plan A is silent on the three biggest risks: native Views/COM rendering, + Graphite/Gecko, and the feature-flag/dual-run mechanics. Plan B scopes all three explicitly. +3. **Boundary type.** Plan A's `SliceSpec` + `IDataTreeView` is essentially a *narrow, concrete + instance* of Plan B's *typed Presentation IR* + `ILexicalEditorRegistry` — but built only for + `DataTree`, with a different vocabulary. +4. **Fidelity/density.** Plan B has the machinery to *prove* fidelity/density (semantic parity + snapshots, render-comparison evidence, perf budgets). Plan A relies on manual smoke tests. +5. **Cost & risk profile.** Plan A is days and near-zero risk but doesn't reach the goal. Plan B is + months and carries real unknowns (net8↔net48 host bridge, browser/PDF replacement) but is the + only one that actually reaches "everything on Avalonia." +6. **Progress already banked.** Plan B has more sunk, reusable work (93+ tests, frozen seam specs, + coverage map, a net8 Avalonia prototype on a sibling branch). Plan A's collaborator/model classes + don't exist yet. + +--- + +## 5. Three ways to proceed + +### Approach 1 — Adopt Plan B wholesale; retire Plan A +Commit to the newer two-change program as written. Resume at Lexical Edit Phase 3 (seam extraction), +then build Avalonia editor slices, parity automation, Graphite gating, and eventually the shell. +Harvest nothing structural from Plan A. + +**Pros** +- Only approach that actually reaches the stated goal (whole app on Avalonia). +- Hard problems already scoped: Graphite/native removal, feature-flag dual-run, parity/density proof. +- ~85% of Phases 1–2 already done and verified (tests green); momentum and frozen decisions exist. +- Single coherent vocabulary and gate model; less reconciliation work. + +**Cons** +- 40–50 week program; risks crowding out other roadmap work; needs sustained staffing. +- Biggest unknowns (net8↔net48 host bridge, browser/PDF replacement) are still unresolved "open questions." +- Heavy process (region manifests, frozen seam specs) can slow the first visible Avalonia pixel. +- Plan A's already-clean DataTree decomposition work is left on the table. + +### Approach 2 — Hybrid: Plan A's DataTree split as the concrete first realization *inside* Plan B's seams +Keep Plan B as the program spine (seams, parity, Graphite gates, flag, shell) **but** execute the +Lexical Edit "first region" by doing Plan A's model/view split concretely: `DataTreeModel` + +`SliceSpec` become the typed-IR/editor-registry realization for the DataTree region, and +`IDataTreeView` gets a second implementation (`AvaloniaDataTreeView`) selected by Plan B's two-adapter +flag. Reconcile the two vocabularies once (SliceSpec ⊂ Typed IR; IDataTreeView ⊂ editor registry). + +**Pros** +- Fastest route to a *flagged, real* Avalonia DataTree view: A's split is the missing "make it + swappable" step, B supplies the flag, parity harness, and gates. +- Reuses partially-done work from **both** branches; little is thrown away. +- `SliceSpec` is a ready-made, battle-tested typed-IR seam for the densest part of Lexical Edit. +- Keeps the big risks (Graphite, native Views, host bridge) visible via B's gates instead of ignoring them. + +**Cons** +- Requires an explicit reconciliation of two designs/vocabularies up front (one-time cost, some churn). +- Risk of building the DataTree boundary slightly "wrong" for the broader IR if alignment is sloppy. +- Still inherits B's long tail (shell, XML retirement) and unresolved host-bridge question. +- Two branches must be merged/curated, which is non-trivial given history rewrites already in play. + +### Approach 3 — Thin vertical POC spike first; defer choosing the full plan +Before committing to A, B, or the hybrid, build the smallest possible **flagged, dual-run** Avalonia +slice: one `LexEntry` field + one chooser popup, hosted next to the WinForms view, using just enough +of B's ports to work. Use it to de-risk the three unknowns that dominate the decision: the +**net8↔net48 host bridge**, **visual fidelity/density** parity, and the **flag/dual-run** switch. +Then pick A-scope, B-scope, or hybrid with real data. + +**Pros** +- Cheapest way to answer the questions that actually decide cost/feasibility (host bridge, density, flag). +- Produces a demoable Avalonia-in-FieldWorks artifact quickly; strong stakeholder signal. +- Low commitment; findings make the subsequent full-plan estimate far more reliable. +- Directly matches the user's "a few small things for proof of concept" framing. + +**Cons** +- The spike itself is partly throwaway; not production value on its own. +- Doesn't advance Graphite/native-Views removal or parity automation (those remain ahead). +- Risk of "POC that lingers" if not time-boxed and followed by a real decision. +- Could under-represent the hard parts (tables, virtualization) that only show up at scale. + +--- + +## 6. Recommendation + +**Do Approach 3 *then* Approach 2** — i.e., run a strictly time-boxed POC spike as the "few small +things for proof of concept," and use it to launch the Hybrid (Plan B spine, Plan A's DataTree split +as the first concrete region). + +Rationale: +- The user explicitly wants to *start with a small POC*, keep the change *behind a flag with dual-run*, + and prioritize *functional fidelity and density* over pixels. Approach 3 is purpose-built to validate + exactly those three things (flag/dual-run, host bridge, density) at minimal cost. +- Once the spike proves the host bridge and density, Approach 2 gives the fastest path to a **real, + flagged Avalonia Lexical Edit view**, because Plan A's `DataTreeModel`/`SliceSpec`/`IDataTreeView` + split is precisely the "make the densest screen swappable" work, while Plan B supplies the flag, + the parity/density proof harness, and the Graphite/native-Views gates that Plan A omits. +- This sequencing banks the sunk work in **both** branches, keeps risk visible, and defers the + expensive shell migration (Plan B Change 2) until the regional pattern is proven — which is exactly + the dependency Plan B itself mandates. + +Concrete next steps: +1. **Spike (time-boxed, ~1–2 weeks):** one editable Avalonia slice + chooser, behind a flag, hosted + beside the WinForms Lexical Edit view; capture host-bridge findings + a semantic/density parity snapshot. +2. **Reconcile vocabularies:** map `SliceSpec` → Plan B typed-IR node, `IDataTreeView` → editor + registry; record the decision in `seam-recommendations.md`. +3. **Land Plan A Phases 0–2** (characterization tests + partial split + collaborator extraction) on + the integration branch — low risk, immediately valuable, and the substrate for the Avalonia view. +4. **Implement `AvaloniaDataTreeView`** selected by Plan B's two-adapter flag; drive it through B's + parity/Graphite/native-audit gates for the Lexical Edit region. +5. **Only then** open Plan B Change 2 (shell), per its own gating. + +--- + +## 7. Source documents reviewed + +**Plan A (branch `datatree-model-view`, in working tree):** +- `openspec/changes/datatree-model-view-separation/{proposal,design,tasks,datatree-mental-model}.md` +- `openspec/changes/datatree-model-view-separation/specs/**` +- companions: `detail-controls-testability`, `retire-linux-era-view-shims`, `render-speedup-benchmark` + +**Plan B (branch `010-advanced-entry-view-phase-1-2`):** +- `openspec/changes/lexical-edit-avalonia-migration/{proposal,design,tasks,architecture-diagrams,migration-map,view-inventory,seam-recommendations,coverage-map,phase2-execution-evidence}.md` and `specs/**` +- `openspec/changes/fieldworks-avalonia-shell-migration/{proposal,design,tasks}.md` and `specs/**` +- (note: `.github/option3-plan.md` is unrelated — it covers CI/CD agent automation, not Avalonia.) diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeDisposalCharacterizationTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeDisposalCharacterizationTests.cs new file mode 100644 index 0000000000..c3d4fe13fd --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeDisposalCharacterizationTests.cs @@ -0,0 +1,187 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using XCore; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Characterization tests that lock down the CURRENT disposal, event-unsubscription, focus, and + /// accessibility behavior of and BEFORE the Avalonia + /// refactor (task 2.7 of lexical-edit-avalonia-migration). These protect the refactor: an Avalonia + /// adapter selected by the two-adapter flag must preserve this observable behavior. + /// + /// They assert observable behavior only (IsDisposed, no-throw after Dispose, AccessibleName, + /// focus order) so they remain robust across the refactor rather than pinning private internals. + /// + [TestFixture] + public class DataTreeDisposalCharacterizationTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private Inventory m_parts; + private Inventory m_layouts; + private ILexEntry m_entry; + private Mediator m_mediator; + private PropertyTable m_propertyTable; + private DataTree m_dtree; + private Form m_parent; + + #region Fixture Setup and Teardown + + public override void FixtureSetup() + { + base.FixtureSetup(); + m_layouts = DataTreeTests.GenerateLayouts(); + m_parts = DataTreeTests.GenerateParts(); + } + + #endregion + + #region Test Setup and Teardown + + public override void TestSetup() + { + base.TestSetup(); + + m_entry = Cache.ServiceLocator.GetInstance().Create(); + m_entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("citation", Cache.DefaultVernWs); + m_entry.Bibliography.SetAnalysisDefaultWritingSystem("bib content"); + m_entry.Bibliography.SetVernacularDefaultWritingSystem("bib content"); + + m_dtree = new DataTree(); + m_mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_mediator); + m_dtree.Init(m_mediator, m_propertyTable, null); + m_parent = new Form(); + m_parent.Controls.Add(m_dtree); + } + + public override void TestTearDown() + { + if (m_parent != null) + { + m_parent.Close(); + m_parent.Dispose(); + m_parent = null; + } + if (m_propertyTable != null) + { + m_propertyTable.Dispose(); + m_propertyTable = null; + } + if (m_mediator != null) + { + m_mediator.Dispose(); + m_mediator = null; + } + + base.TestTearDown(); + } + + #endregion + + private void ShowCfAndBib() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + } + + /// + /// Disposing the DataTree (via its parent form) disposes all realized slices and marks the + /// tree disposed. Locks the cascade in . + /// + [Test] + public void Dispose_AfterShowObject_DisposesAllSlicesAndTree() + { + ShowCfAndBib(); + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2)); + var slice0 = (Slice)m_dtree.Controls[0]; + var slice1 = (Slice)m_dtree.Controls[1]; + + m_parent.Dispose(); + + Assert.That(m_dtree.IsDisposed, Is.True, "DataTree should be disposed via its parent form."); + Assert.That(slice0.IsDisposed, Is.True, "Slice 0 should be disposed with the tree."); + Assert.That(slice1.IsDisposed, Is.True, "Slice 1 should be disposed with the tree."); + } + + /// + /// After disposal the DataTree has removed its LCModel PropChanged notification, so a later + /// model change does not call back into the disposed tree and does not throw. + /// + [Test] + public void Dispose_ThenLcModelChange_DoesNotThrow() + { + ShowCfAndBib(); + m_parent.Dispose(); + + // A model change after disposal must not reach the disposed tree (RemoveNotification(this)). + Assert.That(() => + { + m_entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("changed after dispose", Cache.DefaultVernWs); + }, Throws.Nothing); + } + + /// Disposing twice is safe (the guard in Dispose makes it idempotent). + [Test] + public void Dispose_IsIdempotent() + { + ShowCfAndBib(); + m_parent.Dispose(); + Assert.That(() => m_dtree.Dispose(), Throws.Nothing, "Second Dispose must be a no-op."); + Assert.That(m_dtree.IsDisposed, Is.True); + } + + /// + /// Disposing after assigning a current slice exercises the SetCurrentState(false) path during + /// ; it must not throw. (Note: without a shown form the current + /// slice does not latch, which this test documents rather than fights.) + /// + [Test] + public void Dispose_AfterAssigningCurrentSlice_DoesNotThrow() + { + ShowCfAndBib(); + m_dtree.CurrentSlice = (Slice)m_dtree.Controls[0]; + + Assert.That(() => m_parent.Dispose(), Throws.Nothing); + Assert.That(m_dtree.IsDisposed, Is.True); + } + + /// + /// Each realized slice exposes its label as the accessible name of its control. This is the + /// in-process accessibility "reachability" baseline the Avalonia editors must match. + /// + [Test] + public void Slices_ExposeAccessibleNameMatchingLabel() + { + ShowCfAndBib(); + + var cf = (Slice)m_dtree.Controls[0]; + var bib = (Slice)m_dtree.Controls[1]; + Assert.That(cf.Control.AccessibleName, Is.EqualTo("CitationForm")); + Assert.That(bib.Control.AccessibleName, Is.EqualTo("Bibliography")); + } + + /// + /// The realized slice order (focus order) follows the layout order: CitationForm then + /// Bibliography. Locks the baseline focus order. + /// + [Test] + public void Slices_FocusOrderFollowsLayoutOrder() + { + ShowCfAndBib(); + + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2)); + Assert.That(((Slice)m_dtree.Controls[0]).Label, Is.EqualTo("CitationForm")); + Assert.That(((Slice)m_dtree.Controls[1]).Label, Is.EqualTo("Bibliography")); + Assert.That(m_dtree.Controls.IndexOf((Control)m_dtree.Controls[0]), Is.EqualTo(0)); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs index 03c8336c74..d5c9f89cfe 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs @@ -416,7 +416,12 @@ private async Task VerifyDataTreeBitmap(Bitmap bitmap, string scenarioId) string name = $"DataTreeRenderTests.DataTreeRender_{scenarioId}"; var verification = RenderSnapshotVerifier.Verify(bitmap, directory, name, scenarioId); if (!verification.Passed) + { + // Bundle failure artifacts (received/diff images + summary) for CI diagnosis before failing. + RenderFailureArtifactBundler.BundleFailureArtifacts( + verification, "DataTreeRenderTests", $"DataTreeRender_{scenarioId}", scenarioId); Assert.Fail(verification.FailureMessage); + } await Task.CompletedTask; } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs index 0f74fe0003..78fa2d2088 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs @@ -197,6 +197,61 @@ public void TwoStringAttr() Assert.That((m_dtree.Controls[1] as Slice).Label, Is.EqualTo("Bibliography")); } + [Test] + public void CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2)); + + AssertSemanticSlice( + m_dtree.Controls[0] as Slice, + 0, + "CitationForm", + "CitationForm", + LexEntryTags.kflidCitationForm, + "multistring", + null); + AssertSemanticSlice( + m_dtree.Controls[1] as Slice, + 1, + "Bibliography", + "Bibliography", + LexEntryTags.kflidBibliography, + "multistring", + "ifdata"); + } + + /// + /// Captures the normalized semantic snapshot that the Avalonia POC slice + /// (lexical-edit-avalonia-poc-spike) must reproduce, and proves the snapshot is + /// deterministic across realizations. Every expected value here is already proven by + /// ; this test + /// only locks the reusable snapshot format used by parity comparison. + /// + [Test] + public void SemanticSnapshot_CfAndBib_IsStableAndCapturesPocBaseline() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2)); + + int cfFlid = Cache.MetaDataCacheAccessor.GetFieldId2(LexEntryTags.kClassId, "CitationForm", true); + int bibFlid = Cache.MetaDataCacheAccessor.GetFieldId2(LexEntryTags.kClassId, "Bibliography", true); + string expected = + $"#0 | label=CitationForm | field=CitationForm | flid={cfFlid} | editor=multistring | vis= | a11y=CitationForm" + Environment.NewLine + + $"#1 | label=Bibliography | field=Bibliography | flid={bibFlid} | editor=multistring | vis=ifdata | a11y=Bibliography" + Environment.NewLine; + + string snapshot = BuildSemanticSnapshot(); + Assert.That(snapshot, Is.EqualTo(expected), "POC parity baseline (semantic snapshot) changed."); + + // Determinism: realizing the same object again must yield a byte-for-byte identical snapshot, + // which is the property the Avalonia parity comparison relies on. + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(BuildSemanticSnapshot(), Is.EqualTo(snapshot), + "Semantic snapshot must be deterministic across realizations."); + } + /// [Test] public void LabelAbbreviations() @@ -222,6 +277,53 @@ public void LabelAbbreviations() Assert.That(abbr2 == (m_dtree.Controls[2] as Slice).Abbreviation, Is.False); } + private void AssertSemanticSlice( + Slice slice, + int focusOrder, + string label, + string field, + int flid, + string editor, + string visibility) + { + Assert.That(slice, Is.Not.Null, "Expected a realized slice at focus order {0}.", focusOrder); + Assert.That(m_dtree.Controls.IndexOf(slice), Is.EqualTo(focusOrder)); + Assert.That(slice.Label, Is.EqualTo(label)); + Assert.That(slice.Object.Hvo, Is.EqualTo(m_entry.Hvo)); + Assert.That(slice.Object.ClassID, Is.EqualTo(LexEntryTags.kClassId)); + Assert.That(slice.ConfigurationNode.Attributes["field"].Value, Is.EqualTo(field)); + Assert.That(Cache.MetaDataCacheAccessor.GetFieldId2(LexEntryTags.kClassId, field, true), Is.EqualTo(flid)); + Assert.That(slice.ConfigurationNode.Attributes["editor"].Value, Is.EqualTo(editor)); + Assert.That(slice.CallerNode.Attributes["visibility"]?.Value, Is.EqualTo(visibility)); + Assert.That(slice.Expansion, Is.EqualTo(DataTree.TreeItemState.ktisFixed)); + Assert.That(slice.Control.AccessibleName, Is.EqualTo(label)); + } + + /// + /// Builds a deterministic, normalized semantic snapshot of the currently realized slices. + /// One line per slice: focus order, label, field, flid, editor kind, visibility, and + /// accessibility name. This is the reusable format the Avalonia POC parity comparison keys on. + /// + private string BuildSemanticSnapshot() + { + var sb = new System.Text.StringBuilder(); + for (int i = 0; i < m_dtree.Controls.Count; i++) + { + var slice = m_dtree.Controls[i] as Slice; + if (slice == null) + continue; + string field = slice.ConfigurationNode?.Attributes?["field"]?.Value ?? ""; + int flid = string.IsNullOrEmpty(field) + ? 0 + : Cache.MetaDataCacheAccessor.GetFieldId2(slice.Object.ClassID, field, true); + string editor = slice.ConfigurationNode?.Attributes?["editor"]?.Value ?? ""; + string vis = slice.CallerNode?.Attributes?["visibility"]?.Value ?? ""; + string a11y = slice.Control?.AccessibleName ?? ""; + sb.AppendLine($"#{i} | label={slice.Label} | field={field} | flid={flid} | editor={editor} | vis={vis} | a11y={a11y}"); + } + return sb.ToString(); + } + /// [Test] public void IfDataEmpty() diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeUndoRedoCharacterizationTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeUndoRedoCharacterizationTests.cs new file mode 100644 index 0000000000..2f1a853f47 --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeUndoRedoCharacterizationTests.cs @@ -0,0 +1,206 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Characterization tests that lock down the CURRENT undo/redo transaction behavior for the + /// editor-replacement candidate fields shown by (task 2.6 of + /// lexical-edit-avalonia-migration). They assert on the LCModel source of truth and the action + /// handler's undo/redo state, which is framework-neutral: the Avalonia editors must commit through + /// the same fenced LCModel transactions (see avalonia-edit-sessions / avalonia-undo-redo) and + /// therefore reproduce exactly these undo/redo results. + /// + /// Pattern: the test base opens an ambient undo task during setup; we close it with + /// m_actionHandler.EndUndoTask(), then make discrete undoable edits via + /// + /// and exercise Undo()/Redo(). + /// + [TestFixture] + public class DataTreeUndoRedoCharacterizationTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + private Inventory m_parts; + private Inventory m_layouts; + private ILexEntry m_entry; + private Mediator m_mediator; + private PropertyTable m_propertyTable; + private DataTree m_dtree; + private Form m_parent; + + #region Fixture Setup and Teardown + + public override void FixtureSetup() + { + base.FixtureSetup(); + m_layouts = DataTreeTests.GenerateLayouts(); + m_parts = DataTreeTests.GenerateParts(); + } + + #endregion + + #region Test Setup and Teardown + + public override void TestSetup() + { + base.TestSetup(); + + m_entry = Cache.ServiceLocator.GetInstance().Create(); + m_entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("original-cf", Cache.DefaultVernWs); + m_entry.Bibliography.SetAnalysisDefaultWritingSystem("original-bib"); + m_entry.Bibliography.SetVernacularDefaultWritingSystem("original-bib"); + + m_dtree = new DataTree(); + m_mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_mediator); + m_dtree.Init(m_mediator, m_propertyTable, null); + m_parent = new Form(); + m_parent.Controls.Add(m_dtree); + + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + // Close the ambient setup undo task so the edits below are discrete, undoable units. + m_actionHandler.EndUndoTask(); + } + + public override void TestTearDown() + { + if (m_parent != null) + { + m_parent.Close(); + m_parent.Dispose(); + m_parent = null; + } + if (m_propertyTable != null) + { + m_propertyTable.Dispose(); + m_propertyTable = null; + } + if (m_mediator != null) + { + m_mediator.Dispose(); + m_mediator = null; + } + + base.TestTearDown(); + } + + #endregion + + private string CitationForm => m_entry.CitationForm.VernacularDefaultWritingSystem.Text; + + private string Bibliography => m_entry.Bibliography.AnalysisDefaultWritingSystem.Text; + + private void EditCitationForm(string value) + { + UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, + () => m_entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString(value, Cache.DefaultVernWs)); + } + + /// + /// A multistring (CitationForm) edit can be undone back to the original value and redone to the + /// edited value, and the action handler's CanUndo/CanRedo flags track the cycle. + /// + [Test] + public void UndoRedo_CitationFormEdit_RevertsAndReplays() + { + Assert.That(CitationForm, Is.EqualTo("original-cf")); + + EditCitationForm("edited-cf"); + Assert.That(CitationForm, Is.EqualTo("edited-cf")); + Assert.That(m_actionHandler.CanUndo(), Is.True); + + m_actionHandler.Undo(); + Assert.That(CitationForm, Is.EqualTo("original-cf"), "Undo should restore the original value."); + Assert.That(m_actionHandler.CanRedo(), Is.True); + + m_actionHandler.Redo(); + Assert.That(CitationForm, Is.EqualTo("edited-cf"), "Redo should re-apply the edit."); + } + + /// A multistring (Bibliography) edit undoes/redoes symmetrically. + [Test] + public void UndoRedo_BibliographyEdit_RevertsAndReplays() + { + Assert.That(Bibliography, Is.EqualTo("original-bib")); + + UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, + () => m_entry.Bibliography.SetAnalysisDefaultWritingSystem("edited-bib")); + Assert.That(Bibliography, Is.EqualTo("edited-bib")); + + m_actionHandler.Undo(); + Assert.That(Bibliography, Is.EqualTo("original-bib")); + + m_actionHandler.Redo(); + Assert.That(Bibliography, Is.EqualTo("edited-bib")); + } + + /// + /// Two field edits made inside a single undoable unit collapse into one undo step: a single + /// Undo reverts both fields together. + /// + [Test] + public void UndoRedo_TwoFieldsInOneTask_FormSingleUndoStep() + { + UndoableUnitOfWorkHelper.Do("undo", "redo", m_actionHandler, () => + { + m_entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("cf2", Cache.DefaultVernWs); + m_entry.Bibliography.SetAnalysisDefaultWritingSystem("bib2"); + }); + Assert.That(CitationForm, Is.EqualTo("cf2")); + Assert.That(Bibliography, Is.EqualTo("bib2")); + + // A single undo reverts BOTH fields (one transaction). + m_actionHandler.Undo(); + Assert.That(CitationForm, Is.EqualTo("original-cf")); + Assert.That(Bibliography, Is.EqualTo("original-bib")); + } + + /// + /// After an edit is undone, re-showing the object rebuilds the slice tree from the (reverted) + /// model, so the realized slice reflects the undone value. This characterizes that the visible + /// slice tracks the LCModel source of truth across undo. + /// + [Test] + public void Undo_ThenReshow_SliceReflectsRevertedValue() + { + EditCitationForm("edited-cf"); + m_actionHandler.Undo(); + + // Rebuild the slice tree from the current (reverted) model state. + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + + var cfSlice = (Slice)m_dtree.Controls[0]; + Assert.That(cfSlice.Label, Is.EqualTo("CitationForm")); + Assert.That(CitationForm, Is.EqualTo("original-cf"), + "After undo, the model (and therefore a freshly built slice) shows the original value."); + } + + /// Consecutive distinct edits form distinct undo steps, undone in reverse order. + [Test] + public void UndoRedo_ConsecutiveEdits_AreDistinctSteps() + { + EditCitationForm("v1"); + EditCitationForm("v2"); + Assert.That(CitationForm, Is.EqualTo("v2")); + + m_actionHandler.Undo(); + Assert.That(CitationForm, Is.EqualTo("v1"), "First undo reverts the most recent edit."); + + m_actionHandler.Undo(); + Assert.That(CitationForm, Is.EqualTo("original-cf"), "Second undo reverts the earlier edit."); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs index c8560e9505..0f634929a0 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs @@ -2,6 +2,8 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System; +using System.Collections.Generic; using System.Linq; using System.Windows.Forms; using NUnit.Framework; @@ -161,5 +163,244 @@ public void DoNotRefresh_WithoutRefreshListNeeded_DoesNotRefresh_LT22414_BugDemo "Without RefreshListNeeded, DoNotRefresh=false does not trigger refresh; " + "slices remain stale (bibliography still visible despite no data)."); } + + [Test] + public void DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2)); + + m_dtree.DoNotRefresh = true; + m_entry.Bibliography.SetVernacularDefaultWritingSystem(""); + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(""); + m_dtree.RefreshListNeeded = true; + m_dtree.RefreshListNeeded = false; + + m_dtree.DoNotRefresh = false; + + Assert.That(m_dtree.Controls.Count, Is.EqualTo(2), + "Clearing RefreshListNeeded before releasing DoNotRefresh should cancel the " + + "synchronous rebuild and preserve the current slice tree."); + Assert.That(m_dtree.RefreshListNeeded, Is.False); + } + + [TestCaseSource(nameof(StemLikeMorphTypes))] + public void IsStemType_StemLikeMorphTypes_ReturnsTrue(Guid morphTypeGuid) + { + var morphType = Cache.ServiceLocator.GetInstance().GetObject(morphTypeGuid); + + Assert.That(InvokeIsStemType(morphType), Is.True); + } + + [TestCaseSource(nameof(AffixLikeMorphTypes))] + public void IsStemType_AffixLikeMorphTypes_ReturnsFalse(Guid morphTypeGuid) + { + var morphType = Cache.ServiceLocator.GetInstance().GetObject(morphTypeGuid); + + Assert.That(InvokeIsStemType(morphType), Is.False); + } + + [Test] + public void IsStemType_NullMorphType_ReturnsFalse() + { + Assert.That(InvokeIsStemType(null), Is.False); + } + + [Test] + public void CheckForStemDataLoss_EmptyStemAndNoMorphSyntaxAnalyses_AllowsChangeWithoutPrompt() + { + var stem = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = stem; + + Assert.That(InvokeCheckForStemDataLoss(stem, new List()), Is.False); + } + + [Test] + public void CheckForAffixDataLoss_EmptyAffixAndNoMorphSyntaxAnalyses_AllowsChangeWithoutPrompt() + { + var affix = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = affix; + + Assert.That(InvokeCheckForAffixDataLoss(affix, new List()), Is.False); + } + + [Test] + public void GetStemDataLossKinds_StemNameAndGrammarInfo_FlagsBoth() + { + var stem = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = stem; + var partOfSpeech = CreatePartOfSpeech("phase2-stem-pos"); + stem.StemNameRA = CreateStemName(partOfSpeech, "phase2-stem-name"); + var msa = Cache.ServiceLocator.GetInstance().Create(); + m_entry.MorphoSyntaxAnalysesOC.Add(msa); + msa.InflectionClassRA = CreateInflectionClass(partOfSpeech, "phase2-stem-class"); + + Assert.That( + InvokeGetStemDataLossKinds(stem, new List { msa }), + Is.EqualTo(MorphTypeDataLossKinds.StemName | MorphTypeDataLossKinds.GrammarInfo)); + } + + [Test] + public void GetAffixDataLossKinds_AffixProcessWithInflectionClassAndGrammarInfo_FlagsRuleInflectionClassAndGrammarInfo() + { + var affix = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = affix; + var partOfSpeech = CreatePartOfSpeech("phase2-affix-pos"); + affix.InflectionClassesRC.Add(CreateInflectionClass(partOfSpeech, "phase2-affix-class")); + var msa = Cache.ServiceLocator.GetInstance().Create(); + m_entry.MorphoSyntaxAnalysesOC.Add(msa); + msa.AffixCategoryRA = partOfSpeech; + + Assert.That( + InvokeGetAffixDataLossKinds(affix, new List { msa }), + Is.EqualTo( + MorphTypeDataLossKinds.Rule | + MorphTypeDataLossKinds.InflectionClass | + MorphTypeDataLossKinds.GrammarInfo)); + } + + [Test] + public void GetAffixDataLossKinds_AffixAllomorphWithPositionAndMsEnv_FlagsInfixLocationAndGrammarInfo() + { + var affix = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = affix; + affix.PositionRS.Add(CreateEnvironment("/ _")); + affix.MsEnvPartOfSpeechRA = CreatePartOfSpeech("phase2-infix-pos"); + + Assert.That( + InvokeGetAffixDataLossKinds(affix, new List()), + Is.EqualTo(MorphTypeDataLossKinds.InfixLocation | MorphTypeDataLossKinds.GrammarInfo)); + } + + [Test] + public void LauncherButtonClick_WithValidObject_ReachesChooserDecisionPath() + { + using (var launcher = new RecordingAtomicReferenceLauncher()) + { + launcher.Initialize(Cache, m_entry, LexEntryTags.kflidMorphoSyntaxAnalyses, "MorphoSyntaxAnalysesOC", "analysis"); + + launcher.InvokeLauncherClickForTest(); + + Assert.That(launcher.ChooserInvocationCount, Is.EqualTo(1)); + Assert.That(launcher.LauncherButton.Name, Is.EqualTo("m_btnLauncher")); + Assert.That(launcher.LauncherButton.Enabled, Is.True); + } + } + + private static IEnumerable StemLikeMorphTypes() + { + yield return new TestCaseData(MoMorphTypeTags.kguidMorphBoundRoot).SetName("IsStemType_BoundRoot_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphBoundStem).SetName("IsStemType_BoundStem_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphClitic).SetName("IsStemType_Clitic_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphDiscontiguousPhrase).SetName("IsStemType_DiscontiguousPhrase_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphEnclitic).SetName("IsStemType_Enclitic_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphParticle).SetName("IsStemType_Particle_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphPhrase).SetName("IsStemType_Phrase_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphProclitic).SetName("IsStemType_Proclitic_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphRoot).SetName("IsStemType_Root_ReturnsTrue"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphStem).SetName("IsStemType_Stem_ReturnsTrue"); + } + + private static IEnumerable AffixLikeMorphTypes() + { + yield return new TestCaseData(MoMorphTypeTags.kguidMorphCircumfix).SetName("IsStemType_Circumfix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphInfix).SetName("IsStemType_Infix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphInfixingInterfix).SetName("IsStemType_InfixingInterfix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphPrefix).SetName("IsStemType_Prefix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphPrefixingInterfix).SetName("IsStemType_PrefixingInterfix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphSimulfix).SetName("IsStemType_Simulfix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphSuffix).SetName("IsStemType_Suffix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphSuffixingInterfix).SetName("IsStemType_SuffixingInterfix_ReturnsFalse"); + yield return new TestCaseData(MoMorphTypeTags.kguidMorphSuprafix).SetName("IsStemType_Suprafix_ReturnsFalse"); + } + + private static bool InvokeIsStemType(IMoMorphType morphType) + { + var launcher = new MorphTypeAtomicLauncher(); + return launcher.IsStemType(morphType); + } + + private static bool InvokeCheckForStemDataLoss( + IMoStemAllomorph stem, + List morphSyntaxAnalyses) + { + var launcher = new MorphTypeAtomicLauncher(); + return launcher.CheckForStemDataLoss(stem, morphSyntaxAnalyses); + } + + private static bool InvokeCheckForAffixDataLoss( + IMoAffixForm affix, + List morphSyntaxAnalyses) + { + var launcher = new MorphTypeAtomicLauncher(); + return launcher.CheckForAffixDataLoss(affix, morphSyntaxAnalyses); + } + + private static MorphTypeDataLossKinds InvokeGetStemDataLossKinds( + IMoStemAllomorph stem, + List morphSyntaxAnalyses) + { + var launcher = new MorphTypeAtomicLauncher(); + return launcher.GetStemDataLossKinds(stem, morphSyntaxAnalyses); + } + + private static MorphTypeDataLossKinds InvokeGetAffixDataLossKinds( + IMoAffixForm affix, + List morphSyntaxAnalyses) + { + var launcher = new MorphTypeAtomicLauncher(); + return launcher.GetAffixDataLossKinds(affix, morphSyntaxAnalyses); + } + + private IPartOfSpeech CreatePartOfSpeech(string name) + { + var partOfSpeech = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(partOfSpeech); + partOfSpeech.Name.SetAnalysisDefaultWritingSystem(name); + partOfSpeech.Abbreviation.SetAnalysisDefaultWritingSystem(name); + return partOfSpeech; + } + + private IMoStemName CreateStemName(IPartOfSpeech partOfSpeech, string name) + { + var stemName = Cache.ServiceLocator.GetInstance().Create(); + partOfSpeech.StemNamesOC.Add(stemName); + stemName.Name.SetAnalysisDefaultWritingSystem(name); + stemName.Abbreviation.SetAnalysisDefaultWritingSystem(name); + return stemName; + } + + private IMoInflClass CreateInflectionClass(IPartOfSpeech partOfSpeech, string name) + { + var inflClass = Cache.ServiceLocator.GetInstance().Create(); + partOfSpeech.InflectionClassesOC.Add(inflClass); + inflClass.Name.SetAnalysisDefaultWritingSystem(name); + inflClass.Abbreviation.SetAnalysisDefaultWritingSystem(name); + return inflClass; + } + + private IPhEnvironment CreateEnvironment(string representation) + { + var environment = Cache.ServiceLocator.GetInstance().Create(); + Cache.LanguageProject.PhonologicalDataOA.EnvironmentsOS.Add(environment); + environment.StringRepresentation = TsStringUtils.MakeString(representation, Cache.DefaultVernWs); + return environment; + } + + private sealed class RecordingAtomicReferenceLauncher : MockAtomicReferenceLauncher + { + public int ChooserInvocationCount { get; private set; } + + public void InvokeLauncherClickForTest() + { + OnClick(LauncherButton, EventArgs.Empty); + } + + protected override void HandleChooser() + { + ChooserInvocationCount++; + } + } } } diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/RenderFailureArtifactBundlerTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/RenderFailureArtifactBundlerTests.cs new file mode 100644 index 0000000000..3eb7823bb7 --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/RenderFailureArtifactBundlerTests.cs @@ -0,0 +1,138 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.IO; +using NUnit.Framework; +using SIL.FieldWorks.Common.RenderVerification; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Tests for (task 2.5): on a failed render/parity + /// verification, the bundler must gather the available artifacts and a summary into one + /// CI-discoverable folder, and must be a no-op for a passing result. + /// + [TestFixture] + public class RenderFailureArtifactBundlerTests + { + private string m_workDir; + + [SetUp] + public void SetUp() + { + m_workDir = Path.Combine(Path.GetTempPath(), "FwRenderBundlerTests", Path.GetRandomFileName()); + Directory.CreateDirectory(m_workDir); + } + + [TearDown] + public void TearDown() + { + try + { + if (Directory.Exists(m_workDir)) + { + Directory.Delete(m_workDir, recursive: true); + } + } + catch + { + // Best-effort cleanup. + } + } + + [Test] + public void BundleFailureArtifacts_PassingResult_ReturnsNull() + { + var result = new RenderBaselineVerificationResult { Passed = true }; + var folder = RenderFailureArtifactBundler.BundleFailureArtifacts( + result, "MyTests", "MyMethod", "scenario"); + Assert.That(folder, Is.Null, "A passing verification has nothing to bundle."); + } + + [Test] + public void BundleFailureArtifacts_NullResult_ReturnsNull() + { + Assert.That( + RenderFailureArtifactBundler.BundleFailureArtifacts(null, "C", "M", "s"), + Is.Null); + } + + [Test] + public void BundleFailureArtifacts_PixelMismatch_CopiesArtifactsAndWritesSummary() + { + // Arrange: simulate a pixel-mismatch failure with received + diff images present. + var received = Path.Combine(m_workDir, "snap.received.png"); + var receivedMeta = Path.Combine(m_workDir, "snap.received.json"); + var diff = Path.Combine(m_workDir, "snap.diff.png"); + File.WriteAllText(received, "fake-png-bytes"); + File.WriteAllText(receivedMeta, "{}"); + File.WriteAllText(diff, "fake-diff-bytes"); + + var outputRoot = Path.Combine(m_workDir, "out"); + var result = new RenderBaselineVerificationResult + { + Passed = false, + FailureMessage = "pixels differ", + VerifiedPath = Path.Combine(m_workDir, "snap.verified.png"), + ReceivedPath = received, + ReceivedMetadataPath = receivedMeta, + DiffPath = diff, + DiffSummary = new RenderPixelDiffSummary + { + DifferentPixelCount = 42, + DiffRegionWidth = 10, + DiffRegionHeight = 5 + } + }; + + // Act + var folder = RenderFailureArtifactBundler.BundleFailureArtifacts( + result, "DataTreeRenderTests", "DataTreeRender_simple", "simple", outputRoot); + + // Assert: bundle folder under the provided root, with copied artifacts and a summary. + Assert.That(folder, Is.Not.Null); + Assert.That(Directory.Exists(folder), Is.True); + Assert.That(folder, Does.StartWith(outputRoot)); + Assert.That(File.Exists(Path.Combine(folder, "actual.png")), Is.True); + Assert.That(File.Exists(Path.Combine(folder, "actual-metadata.json")), Is.True); + Assert.That(File.Exists(Path.Combine(folder, "diff.png")), Is.True); + Assert.That(File.Exists(Path.Combine(folder, "expected-image-path.txt")), Is.True); + + var summaryPath = Path.Combine(folder, "failure-summary.json"); + Assert.That(File.Exists(summaryPath), Is.True); + var summary = File.ReadAllText(summaryPath); + Assert.That(summary, Does.Contain("\"failureKind\":\"pixel-mismatch\"")); + Assert.That(summary, Does.Contain("\"differentPixelCount\":42")); + Assert.That(summary, Does.Contain("\"scenarioId\":\"simple\"")); + } + + [Test] + public void BundleFailureArtifacts_MissingBaseline_StillBundlesSummary() + { + // Arrange: missing-baseline failure (only a received image, no diff, baseline absent). + var received = Path.Combine(m_workDir, "snap.received.png"); + File.WriteAllText(received, "fake-png-bytes"); + + var outputRoot = Path.Combine(m_workDir, "out"); + var result = new RenderBaselineVerificationResult + { + Passed = false, + FailureMessage = "no baseline", + VerifiedPath = Path.Combine(m_workDir, "does-not-exist.verified.png"), + ReceivedPath = received + }; + + // Act + var folder = RenderFailureArtifactBundler.BundleFailureArtifacts( + result, "DataTreeRenderTests", "DataTreeRender_simple", "simple", outputRoot); + + // Assert: bundle exists; diff is absent; summary classifies it as missing-baseline. + Assert.That(folder, Is.Not.Null); + Assert.That(File.Exists(Path.Combine(folder, "actual.png")), Is.True); + Assert.That(File.Exists(Path.Combine(folder, "diff.png")), Is.False, "No diff for a missing baseline."); + var summary = File.ReadAllText(Path.Combine(folder, "failure-summary.json")); + Assert.That(summary, Does.Contain("\"failureKind\":\"missing-baseline\"")); + } + } +} diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceFactoryTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceFactoryTests.cs index 4fbc577334..7c6c613209 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/SliceFactoryTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/SliceFactoryTests.cs @@ -49,6 +49,28 @@ public void SetConfigurationDisplayPropertyIfNeeded_Works() AssertThatXmlIn.String(configurationNode.OuterXml).HasSpecifiedNumberOfMatchesForXpath("/slice/deParams[@displayProperty]", 1); } + [Test] + public void Create_UnknownEditor_ReturnsMessageSliceWithEditorAccessibleName() + { + XmlNode configurationNode = DetailControls.SliceTests.CreateXmlElementFromOuterXmlOf(""); + ICmObject cmObject = new CmObjectStub(); + + Slice slice = SliceFactory.Create( + Cache, + "unknown-phase3-editor", + 0, + configurationNode, + cmObject, + null, + null, + null, + configurationNode, + new ObjSeqHashMap()); + + Assert.That(slice, Is.TypeOf()); + Assert.That(slice.AccessibleName, Is.EqualTo("unknown-phase3-editor")); + } + class FdoServiceLocatorStub : ILcmServiceLocator { ICmPossibility m_returnObject; diff --git a/Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs b/Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs index d8e8c9b7ae..0e41657cdc 100644 --- a/Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs +++ b/Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs @@ -16,6 +16,17 @@ namespace SIL.FieldWorks.Common.Framework.DetailControls { + [Flags] + internal enum MorphTypeDataLossKinds + { + None = 0, + InflectionClass = 1, + InfixLocation = 2, + GrammarInfo = 4, + Rule = 8, + StemName = 16, + } + public class MorphTypeAtomicLauncher : PossibilityAtomicReferenceLauncher { private const string m_ksPath = "/group[@id='DialogStrings']/"; @@ -223,26 +234,68 @@ private bool ChangeAffixToStem(ILexEntry entry, IMoMorphType type) return true; } - private bool CheckForAffixDataLoss(IMoAffixForm affix, List rgmsaAffix) + internal bool CheckForAffixDataLoss(IMoAffixForm affix, List rgmsaAffix) { - bool fLoseInflCls = affix.InflectionClassesRC.Count > 0; - bool fLoseInfixLoc = false; - bool fLoseGramInfo = false; - bool fLoseRule = false; + var dataLossKinds = GetAffixDataLossKinds(affix, rgmsaAffix); + bool fLoseInflCls = (dataLossKinds & MorphTypeDataLossKinds.InflectionClass) != 0; + bool fLoseInfixLoc = (dataLossKinds & MorphTypeDataLossKinds.InfixLocation) != 0; + bool fLoseGramInfo = (dataLossKinds & MorphTypeDataLossKinds.GrammarInfo) != 0; + bool fLoseRule = (dataLossKinds & MorphTypeDataLossKinds.Rule) != 0; + if (dataLossKinds != MorphTypeDataLossKinds.None) + { + string sMsg; + if (fLoseInflCls && fLoseInfixLoc && fLoseGramInfo) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsInfixLocGramInfo", m_ksPath); + else if (fLoseRule && fLoseInflCls && fLoseGramInfo) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRuleInflClsGramInfo", m_ksPath); + else if (fLoseInflCls && fLoseInfixLoc) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsInfixLoc", m_ksPath); + else if (fLoseInflCls && fLoseGramInfo) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsGramInfo", m_ksPath); + else if (fLoseInfixLoc && fLoseGramInfo) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInfixLocGramInfo", m_ksPath); + else if (fLoseRule && fLoseInflCls) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRuleInflCls", m_ksPath); + else if (fLoseRule) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRule", m_ksPath); + else if (fLoseInflCls) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflCls", m_ksPath); + else if (fLoseInfixLoc) + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInfixLoc", m_ksPath); + else + sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseGramInfo", m_ksPath); + string sCaption = StringTable.Table.GetStringWithXPath("ChangeLexemeMorphTypeCaption", m_ksPath); + DialogResult result = MessageBox.Show(sMsg, sCaption, + MessageBoxButtons.YesNo, MessageBoxIcon.Warning); + if (result == DialogResult.No) + { + return true; + } + } + return false; + } + + internal MorphTypeDataLossKinds GetAffixDataLossKinds(IMoAffixForm affix, IList rgmsaAffix) + { + var dataLossKinds = MorphTypeDataLossKinds.None; + if (affix.InflectionClassesRC.Count > 0) + dataLossKinds |= MorphTypeDataLossKinds.InflectionClass; switch (affix.ClassID) { case MoAffixProcessTags.kClassId: - fLoseRule = true; + dataLossKinds |= MorphTypeDataLossKinds.Rule; break; case MoAffixAllomorphTags.kClassId: var allo = (IMoAffixAllomorph) affix; - fLoseInfixLoc = allo.PositionRS.Count > 0; - fLoseGramInfo = allo.MsEnvPartOfSpeechRA != null || allo.MsEnvFeaturesOA != null; + if (allo.PositionRS.Count > 0) + dataLossKinds |= MorphTypeDataLossKinds.InfixLocation; + if (allo.MsEnvPartOfSpeechRA != null || allo.MsEnvFeaturesOA != null) + dataLossKinds |= MorphTypeDataLossKinds.GrammarInfo; break; } - for (int i = 0; !fLoseGramInfo && i < rgmsaAffix.Count; ++i) + for (int i = 0; (dataLossKinds & MorphTypeDataLossKinds.GrammarInfo) == 0 && i < rgmsaAffix.Count; ++i) { var msaInfl = rgmsaAffix[i] as IMoInflAffMsa; if (msaInfl != null) @@ -252,7 +305,7 @@ private bool CheckForAffixDataLoss(IMoAffixForm affix, List msaInfl.SlotsRC.Count > 0 || msaInfl.InflFeatsOA != null) { - fLoseGramInfo = true; + dataLossKinds |= MorphTypeDataLossKinds.GrammarInfo; } continue; } @@ -270,7 +323,7 @@ private bool CheckForAffixDataLoss(IMoAffixForm affix, List msaDeriv.FromMsFeaturesOA != null || msaDeriv.ToMsFeaturesOA != null) { - fLoseGramInfo = true; + dataLossKinds |= MorphTypeDataLossKinds.GrammarInfo; } continue; } @@ -282,42 +335,11 @@ private bool CheckForAffixDataLoss(IMoAffixForm affix, List msaStep.InflFeatsOA != null || msaStep.MsFeaturesOA != null) { - fLoseGramInfo = true; + dataLossKinds |= MorphTypeDataLossKinds.GrammarInfo; } } } - if (fLoseInflCls || fLoseInfixLoc || fLoseGramInfo || fLoseRule) - { - string sMsg; - if (fLoseInflCls && fLoseInfixLoc && fLoseGramInfo) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsInfixLocGramInfo", m_ksPath); - else if (fLoseRule && fLoseInflCls && fLoseGramInfo) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRuleInflClsGramInfo", m_ksPath); - else if (fLoseInflCls && fLoseInfixLoc) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsInfixLoc", m_ksPath); - else if (fLoseInflCls && fLoseGramInfo) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflClsGramInfo", m_ksPath); - else if (fLoseInfixLoc && fLoseGramInfo) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInfixLocGramInfo", m_ksPath); - else if (fLoseRule && fLoseInflCls) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRuleInflCls", m_ksPath); - else if (fLoseRule) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseRule", m_ksPath); - else if (fLoseInflCls) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInflCls", m_ksPath); - else if (fLoseInfixLoc) - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseInfixLoc", m_ksPath); - else - sMsg = StringTable.Table.GetStringWithXPath("ChangeMorphTypeLoseGramInfo", m_ksPath); - string sCaption = StringTable.Table.GetStringWithXPath("ChangeLexemeMorphTypeCaption", m_ksPath); - DialogResult result = MessageBox.Show(sMsg, sCaption, - MessageBoxButtons.YesNo, MessageBoxIcon.Warning); - if (result == DialogResult.No) - { - return true; - } - } - return false; + return dataLossKinds; } private bool ChangeStemToAffix(ILexEntry entry, IMoMorphType type) @@ -342,24 +364,11 @@ private bool ChangeStemToAffix(ILexEntry entry, IMoMorphType type) return true; } - private bool CheckForStemDataLoss(IMoStemAllomorph stem, List rgmsaStem) + internal bool CheckForStemDataLoss(IMoStemAllomorph stem, List rgmsaStem) { - bool fLoseStemName = stem.StemNameRA != null; - bool fLoseGramInfo = false; - for (int i = 0; i < rgmsaStem.Count; ++i) - { - var msa = rgmsaStem[i] as IMoStemMsa; - if (msa != null && - (msa.FromPartsOfSpeechRC.Count > 0 || - msa.InflectionClassRA != null || - msa.ProdRestrictRC.Count > 0 || - msa.StratumRA != null || - msa.MsFeaturesOA != null)) - { - fLoseGramInfo = true; - break; - } - } + var dataLossKinds = GetStemDataLossKinds(stem, rgmsaStem); + bool fLoseStemName = (dataLossKinds & MorphTypeDataLossKinds.StemName) != 0; + bool fLoseGramInfo = (dataLossKinds & MorphTypeDataLossKinds.GrammarInfo) != 0; if (fLoseStemName || fLoseGramInfo) { string sMsg; @@ -380,6 +389,27 @@ private bool CheckForStemDataLoss(IMoStemAllomorph stem, List rgmsaStem) + { + var dataLossKinds = stem.StemNameRA != null + ? MorphTypeDataLossKinds.StemName + : MorphTypeDataLossKinds.None; + for (int i = 0; (dataLossKinds & MorphTypeDataLossKinds.GrammarInfo) == 0 && i < rgmsaStem.Count; ++i) + { + var msa = rgmsaStem[i] as IMoStemMsa; + if (msa != null && + (msa.FromPartsOfSpeechRC.Count > 0 || + msa.InflectionClassRA != null || + msa.ProdRestrictRC.Count > 0 || + msa.StratumRA != null || + msa.MsFeaturesOA != null)) + { + dataLossKinds |= MorphTypeDataLossKinds.GrammarInfo; + } + } + return dataLossKinds; + } + internal void SwapValues(ILexEntry entry, IMoForm origForm, IMoForm newForm, IMoMorphType type, List rgmsaOld) { @@ -425,7 +455,7 @@ internal void SwapValues(ILexEntry entry, IMoForm origForm, IMoForm newForm, IMo } } - private bool IsStemType(IMoMorphType type) + internal bool IsStemType(IMoMorphType type) { if (type == null) return false; diff --git a/Src/Common/FieldWorks/FieldWorks.cs b/Src/Common/FieldWorks/FieldWorks.cs index 9e01e6df2c..904b05b73a 100644 --- a/Src/Common/FieldWorks/FieldWorks.cs +++ b/Src/Common/FieldWorks/FieldWorks.cs @@ -2912,6 +2912,8 @@ internal static bool CreateAndInitNewMainWindow(FwApp app, bool fNewCache, Form EnsureValidReversalIndexConfigFile(app.Cache); s_activeMainWnd.PropTable.SetProperty("AppSettings", s_appSettings, false); s_activeMainWnd.PropTable.SetPropertyPersistence("AppSettings", false); + s_activeMainWnd.PropTable.SetProperty("UIMode", string.IsNullOrWhiteSpace(s_appSettings.UIMode) ? "Legacy" : s_appSettings.UIMode, false); + s_activeMainWnd.PropTable.SetPropertyPersistence("UIMode", false); } catch (StartupException ex) { diff --git a/Src/Common/FwAvalonia/FwAvalonia.csproj b/Src/Common/FwAvalonia/FwAvalonia.csproj new file mode 100644 index 0000000000..cc56071933 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvalonia.csproj @@ -0,0 +1,45 @@ + + + + + net48 + latest + disable + false + + false + false + $(NoWarn);CS1591;NU1701 + + + + + + + + + + + + + + + + + + + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj new file mode 100644 index 0000000000..7ce477c73d --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj @@ -0,0 +1,43 @@ + + + + + net48 + latest + disable + true + false + false + $(NoWarn);CS1591;NU1701 + + + + + + + + + + + + + + + + + + + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs new file mode 100644 index 0000000000..50f54e1df4 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace FwAvaloniaTests +{ + /// + /// Pure-logic tests for the two-adapter feature flag and surface factory. No Avalonia runtime + /// is required, which is itself part of the evidence: the default (flag off) path constructs + /// nothing Avalonia. + /// + [TestFixture] + public class LexicalEditSurfaceResolverTests + { + [Test] + public void Resolve_DefaultsToWinForms_WhenFlagUnset() + { + var surface = LexicalEditSurfaceResolver.Resolve(envReader: _ => null); + Assert.That(surface, Is.EqualTo(LexicalEditSurface.WinForms)); + } + + [TestCase("1")] + [TestCase("true")] + [TestCase("TRUE")] + [TestCase("on")] + [TestCase("yes")] + public void Resolve_SelectsAvalonia_WhenFlagTruthy(string value) + { + var surface = LexicalEditSurfaceResolver.Resolve( + envReader: name => name == LexicalEditSurfaceResolver.FlagEnvVar ? value : null); + Assert.That(surface, Is.EqualTo(LexicalEditSurface.Avalonia)); + } + + [TestCase("")] + [TestCase("0")] + [TestCase("false")] + [TestCase("off")] + [TestCase("nonsense")] + public void Resolve_StaysWinForms_WhenFlagFalsy(string value) + { + var surface = LexicalEditSurfaceResolver.Resolve( + envReader: name => name == LexicalEditSurfaceResolver.FlagEnvVar ? value : null); + Assert.That(surface, Is.EqualTo(LexicalEditSurface.WinForms)); + } + + [Test] + public void Resolve_OverrideWinsOverEnvironment() + { + // Environment says "on", but the explicit override says off -> WinForms. + var winForms = LexicalEditSurfaceResolver.Resolve( + envReader: _ => "1", overrideEnabled: false); + Assert.That(winForms, Is.EqualTo(LexicalEditSurface.WinForms)); + + // Environment unset, but override says on -> Avalonia. + var avalonia = LexicalEditSurfaceResolver.Resolve( + envReader: _ => null, overrideEnabled: true); + Assert.That(avalonia, Is.EqualTo(LexicalEditSurface.Avalonia)); + } + } + + /// Tests that the factory never constructs the Avalonia surface when the flag is off. + [TestFixture] + public class LexicalEditSurfaceFactoryTests + { + [Test] + public void Create_FlagOff_DoesNotConstructAvaloniaRuntime() + { + var avaloniaBuilds = 0; + var factory = new LexicalEditSurfaceFactory( + winFormsSurfaceBuilder: () => "winforms", + avaloniaSurfaceBuilder: () => { avaloniaBuilds++; return "avalonia"; }); + + var result = factory.Create(LexicalEditSurface.WinForms); + + Assert.That(result, Is.EqualTo("winforms")); + Assert.That(avaloniaBuilds, Is.EqualTo(0), "Avalonia builder must not run when the flag is off."); + Assert.That(factory.AvaloniaConstructionCount, Is.EqualTo(0)); + } + + [Test] + public void Create_FlagOn_ConstructsAvaloniaOnce() + { + var avaloniaBuilds = 0; + var factory = new LexicalEditSurfaceFactory( + winFormsSurfaceBuilder: () => "winforms", + avaloniaSurfaceBuilder: () => { avaloniaBuilds++; return "avalonia"; }); + + var result = factory.Create(LexicalEditSurface.Avalonia); + + Assert.That(result, Is.EqualTo("avalonia")); + Assert.That(avaloniaBuilds, Is.EqualTo(1)); + Assert.That(factory.AvaloniaConstructionCount, Is.EqualTo(1)); + } + } + + /// + /// Audits the POC assembly's references to prove it carries no native Views or Graphite + /// dependency, satisfying the spike's "no native viewing or Graphite" requirement at the + /// assembly-reference level (the headless render test proves it at runtime). + /// + [TestFixture] + public class PocAssemblyReferenceAuditTests + { + [Test] + public void PocAssembly_HasNoNativeViewsOrGraphiteReferences() + { + var referenced = typeof(PocLexEntrySlice).Assembly.GetReferencedAssemblies(); + var forbidden = new[] { "Graphite", "ViewsInterfaces", "Views.dll", "RootSite", "Gecko", "Geckofx" }; + + foreach (var name in referenced.Select(r => r.Name)) + { + foreach (var bad in forbidden) + { + Assert.That( + name.IndexOf(bad, StringComparison.OrdinalIgnoreCase), + Is.LessThan(0), + $"POC assembly must not reference '{bad}', but references '{name}'."); + } + } + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs new file mode 100644 index 0000000000..97fb02a02a --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs @@ -0,0 +1,128 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using System.Text; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace FwAvaloniaTests +{ + /// + /// Headless tests for the Avalonia POC slice. These run on .NET Framework 4.8 via + /// Avalonia.Headless.NUnit and are the spike's primary host-bridge evidence: if they pass, + /// Avalonia 11.3.x loads and renders editable FieldWorks-owned controls on net48. + /// + [TestFixture] + public class PocLexEntrySliceTests + { + private static (PocLexEntrySlice slice, Window window) ShowSlice() + { + var entry = PocEntryDto.CreateSample(); + var slice = new PocLexEntrySlice(entry); + var window = new Window { Content = slice, Width = 420, Height = 320 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (slice, window); + } + + [AvaloniaTest] + public void PocSlice_RendersThreeEditors() + { + var (slice, _) = ShowSlice(); + + Assert.That(slice.LexemeFormEditor, Is.Not.Null); + Assert.That(slice.LexemeFormEditor.Boxes.Count, Is.EqualTo(2), "two writing systems on the lexeme form"); + Assert.That(slice.MorphTypeChooser, Is.Not.Null); + Assert.That(slice.MorphTypeChooser.Button, Is.Not.Null); + Assert.That(slice.SenseGlossEditor, Is.Not.Null); + Assert.That(slice.SenseGlossEditor.Boxes.Count, Is.EqualTo(2), "two writing systems on the gloss"); + } + + [AvaloniaTest] + public void PocSlice_WsText_UsesConfiguredFont() + { + var (slice, _) = ShowSlice(); + + Assert.That(slice.LexemeFormEditor.Boxes[0].FontFamily.Name, Is.EqualTo("Charis SIL")); + Assert.That(slice.LexemeFormEditor.Boxes[1].FontFamily.Name, Is.EqualTo("Times New Roman")); + } + + [AvaloniaTest] + public void PocSlice_Edit_WritesThroughToEntry_AndCommitKeepsAndCancelRestores() + { + var (slice, _) = ShowSlice(); + var entry = slice.Entry; + + // Commit path: an edit writes through to the entry and survives commit. + var session = new PocEditSession(entry); + slice.LexemeFormEditor.Boxes[0].Text = "edited"; + Dispatcher.UIThread.RunJobs(); + Assert.That(entry.LexemeForm[0].Value, Is.EqualTo("edited"), "edit should write through to the entry"); + session.Commit(); + Assert.That(entry.LexemeForm[0].Value, Is.EqualTo("edited"), "commit keeps the edit"); + + // Cancel path: a subsequent edit is rolled back to the snapshot. + var session2 = new PocEditSession(entry); + slice.LexemeFormEditor.Boxes[0].Text = "temp"; + Dispatcher.UIThread.RunJobs(); + session2.Cancel(); + Assert.That(entry.LexemeForm[0].Value, Is.EqualTo("edited"), "cancel restores the snapshot"); + } + + [AvaloniaTest] + public void PocSlice_MorphTypeChooser_UpdatesEntryAndReturnsFocus() + { + var (slice, _) = ShowSlice(); + var entry = slice.Entry; + + slice.MorphTypeChooser.Button.Focus(); + slice.MorphTypeChooser.Open(); + Dispatcher.UIThread.RunJobs(); + + var suffix = entry.MorphTypeOptions.Single(o => o.Key == "suffix"); + slice.MorphTypeChooser.Select(suffix); + Dispatcher.UIThread.RunJobs(); + + Assert.That(entry.MorphTypeKey, Is.EqualTo("suffix"), "choosing updates the entry"); + Assert.That(slice.MorphTypeChooser.Button.Content, Is.EqualTo("suffix"), "button label reflects the choice"); + Assert.That(slice.MorphTypeChooser.Button.IsFocused, Is.True, "focus returns to the host button after choosing"); + } + + [AvaloniaTest] + public void PocSlice_SemanticSnapshot_IsDeterministic() + { + var (slice, _) = ShowSlice(); + + var first = BuildPocSnapshot(slice); + var second = BuildPocSnapshot(slice); + + Assert.That(second, Is.EqualTo(first), "POC semantic snapshot must be deterministic for parity comparison"); + // Sanity: the snapshot captures the three fields and their editor kinds. + Assert.That(first, Does.Contain("Lexeme Form | editor=multiws-text")); + Assert.That(first, Does.Contain("Morph Type | editor=popup-chooser")); + Assert.That(first, Does.Contain("Gloss | editor=multiws-text")); + } + + /// + /// Builds a normalized semantic snapshot of the Avalonia slice in the same spirit as the + /// WinForms baseline (see DataTreeTests.SemanticSnapshot_*). The two are compared in the + /// spike evidence report; they are not asserted equal because the POC uses detached DTO data. + /// + private static string BuildPocSnapshot(PocLexEntrySlice slice) + { + var sb = new StringBuilder(); + sb.AppendLine($"#0 | Lexeme Form | editor=multiws-text | ws={WsList(slice.LexemeFormEditor)}"); + sb.AppendLine($"#1 | Morph Type | editor=popup-chooser | value={slice.Entry.MorphTypeKey}"); + sb.AppendLine($"#2 | Gloss | editor=multiws-text | ws={WsList(slice.SenseGlossEditor)}"); + return sb.ToString(); + } + + private static string WsList(MultiWsTextEditor editor) + => string.Join(",", editor.Alternatives.Select(a => a.WsAbbrev)); + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs new file mode 100644 index 0000000000..3f05fae055 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs @@ -0,0 +1,193 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Seams; + +namespace FwAvaloniaTests +{ + [TestFixture] + public class RefreshCoordinatorTests + { + [Test] + public void RequestRefresh_RunsImmediately_WhenNotSuspended() + { + var c = new RefreshCoordinator(); + Assert.That(c.RequestRefresh(), Is.True); + Assert.That(c.RefreshPending, Is.False); + } + + [Test] + public void RequestRefresh_IsSuppressedAndPending_WhenSuspended() + { + var c = new RefreshCoordinator(); + c.BeginSuspend(); + Assert.That(c.RequestRefresh(), Is.False, "refresh should be suppressed while suspended"); + Assert.That(c.RefreshPending, Is.True); + } + + [Test] + public void EndSuspend_ReportsRefreshDue_WhenRequestedWhileSuspended() + { + var c = new RefreshCoordinator(); + c.BeginSuspend(); + c.RequestRefresh(); + Assert.That(c.EndSuspend(), Is.True, "a refresh requested while suspended is due on release"); + Assert.That(c.RefreshPending, Is.False); + } + + [Test] + public void EndSuspend_ReportsNothingDue_WhenNoRefreshRequested() + { + var c = new RefreshCoordinator(); + c.BeginSuspend(); + Assert.That(c.EndSuspend(), Is.False); + } + } + + [TestFixture] + public class LexicalEditorRegistryTests + { + [Test] + public void Resolve_ReturnsFallback_ForUnregisteredKey() + { + var registry = new LexicalEditorRegistry(fallbackHandler: "legacy"); + Assert.That(registry.IsRegistered("multistring"), Is.False); + Assert.That(registry.Resolve("multistring"), Is.EqualTo("legacy")); + } + + [Test] + public void Resolve_ReturnsRegisteredHandler_OverFallback() + { + var registry = new LexicalEditorRegistry(fallbackHandler: "legacy"); + registry.Register("multistring", "avalonia-text"); + Assert.That(registry.IsRegistered("multistring"), Is.True); + Assert.That(registry.Resolve("multistring"), Is.EqualTo("avalonia-text")); + } + + [Test] + public void Register_RejectsNullHandlerAndEmptyKey() + { + var registry = new LexicalEditorRegistry(); + Assert.That(() => registry.Register("k", null), Throws.ArgumentNullException); + Assert.That(() => registry.Register("", "h"), Throws.ArgumentException); + } + } + + [TestFixture] + public class RegionLifetimeAndSchedulerTests + { + private sealed class Spy : IDisposable + { + private readonly Action _onDispose; + public Spy(Action onDispose) { _onDispose = onDispose; } + public void Dispose() => _onDispose(); + } + + [Test] + public void RegionLifetime_DisposesRegistered_InReverseOrder_Once() + { + var order = new System.Collections.Generic.List(); + var region = new RegionLifetime(); + region.Register(new Spy(() => order.Add(1))); + region.Register(new Spy(() => order.Add(2))); + + region.Dispose(); + region.Dispose(); // idempotent + + Assert.That(order, Is.EqualTo(new[] { 2, 1 })); + Assert.That(region.IsDisposed, Is.True); + } + + [Test] + public void RegionLifetime_LateRegistration_DisposesImmediately() + { + var disposed = false; + var region = new RegionLifetime(); + region.Dispose(); + region.Register(new Spy(() => disposed = true)); + Assert.That(disposed, Is.True); + } + + [Test] + public void ImmediateUiScheduler_RunsSynchronously() + { + var scheduler = new ImmediateUiScheduler(); + var ran = false; + scheduler.Post(() => ran = true); + Assert.That(ran, Is.True); + Assert.That(scheduler.IsOnUiThread, Is.True); + } + + [Test] + public void InMemoryPropertyStateStore_RoundTripsTypedValues() + { + var store = new InMemoryPropertyStateStore(); + store.Set("count", 7); + Assert.That(store.TryGet("count", out var v), Is.True); + Assert.That(v, Is.EqualTo(7)); + Assert.That(store.TryGet("count", out _), Is.False, "wrong type should not match"); + Assert.That(store.Remove("count"), Is.True); + Assert.That(store.TryGet("count", out _), Is.False); + } + } + + [TestFixture] + public class MorphTypeSwapLogicTests + { + [TestCase(MorphTypeKind.Root)] + [TestCase(MorphTypeKind.Stem)] + [TestCase(MorphTypeKind.BoundRoot)] + [TestCase(MorphTypeKind.BoundStem)] + [TestCase(MorphTypeKind.Enclitic)] + [TestCase(MorphTypeKind.Proclitic)] + [TestCase(MorphTypeKind.Clitic)] + [TestCase(MorphTypeKind.Particle)] + [TestCase(MorphTypeKind.Phrase)] + [TestCase(MorphTypeKind.DiscontiguousPhrase)] + public void IsStemType_True_ForStemLikeTypes(MorphTypeKind kind) + { + Assert.That(MorphTypeSwapLogic.IsStemType(kind), Is.True); + } + + [TestCase(MorphTypeKind.Prefix)] + [TestCase(MorphTypeKind.Suffix)] + [TestCase(MorphTypeKind.Infix)] + [TestCase(MorphTypeKind.Simulfix)] + [TestCase(MorphTypeKind.Suprafix)] + [TestCase(MorphTypeKind.Circumfix)] + [TestCase(MorphTypeKind.PrefixingInterfix)] + [TestCase(MorphTypeKind.InfixingInterfix)] + [TestCase(MorphTypeKind.SuffixingInterfix)] + public void IsStemType_False_ForAffixLikeTypes(MorphTypeKind kind) + { + Assert.That(MorphTypeSwapLogic.IsStemType(kind), Is.False); + } + + [Test] + public void Analyze_StemToAffix_RequiresDataLossPrompt() + { + var d = MorphTypeSwapLogic.Analyze(MorphTypeKind.Stem, MorphTypeKind.Suffix); + Assert.That(d.RequiresDataLossPrompt, Is.True); + Assert.That(d.Direction, Is.EqualTo(MorphSwapDirection.StemToAffix)); + } + + [Test] + public void Analyze_AffixToStem_RequiresDataLossPrompt() + { + var d = MorphTypeSwapLogic.Analyze(MorphTypeKind.Prefix, MorphTypeKind.Root); + Assert.That(d.RequiresDataLossPrompt, Is.True); + Assert.That(d.Direction, Is.EqualTo(MorphSwapDirection.AffixToStem)); + } + + [Test] + public void Analyze_SameSide_DoesNotPrompt() + { + Assert.That(MorphTypeSwapLogic.Analyze(MorphTypeKind.Stem, MorphTypeKind.Root).RequiresDataLossPrompt, Is.False); + Assert.That(MorphTypeSwapLogic.Analyze(MorphTypeKind.Prefix, MorphTypeKind.Suffix).RequiresDataLossPrompt, Is.False); + Assert.That(MorphTypeSwapLogic.Analyze(MorphTypeKind.Stem, MorphTypeKind.Stem).RequiresDataLossPrompt, Is.False); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs new file mode 100644 index 0000000000..4341ed04b0 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs @@ -0,0 +1,24 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia; +using Avalonia.Headless; +using FwAvaloniaTests; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +[assembly: AvaloniaTestApplication(typeof(TestAppBuilder))] + +namespace FwAvaloniaTests +{ + /// + /// Headless Avalonia application builder for the POC tests. Uses so the + /// Fluent theme is applied and the pure-C# controls receive templates under the headless platform. + /// + public static class TestAppBuilder + { + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UseHeadless(new AvaloniaHeadlessPlatformOptions()); + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs new file mode 100644 index 0000000000..982542f18a --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs @@ -0,0 +1,272 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using System.Threading; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Tests for importing legacy XML Parts/Layout into the typed view definition. The inline XML + /// mirrors the real schema used by DetailControlsTests/Test.fwlayout and TestParts.xml. + /// + [TestFixture] + public class XmlLayoutImporterTests + { + private const string PartsXml = @" + + + + + + + + + + + + + + + + + + + + + + + + + +"; + + private static ViewDefinitionModel Import(string layoutXml) + { + var parts = new DictionaryPartResolver(XElement.Parse(PartsXml)); + return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts); + } + + [Test] + public void Import_CfAndBib_ProducesTwoFieldsWithStableBindings() + { + var model = Import(@" + + + +"); + + Assert.That(model.Roots.Count, Is.EqualTo(2)); + Assert.That(model.Diagnostics, Is.Empty); + + var cf = model.Roots[0]; + Assert.That(cf.StableId, Is.EqualTo("LexEntry/CfAndBib/#0")); + Assert.That(cf.Kind, Is.EqualTo(ViewNodeKind.Field)); + Assert.That(cf.Field, Is.EqualTo("CitationForm")); + Assert.That(cf.RawEditor, Is.EqualTo("multistring")); + Assert.That(cf.EditorClassification, Is.EqualTo(EditorClassification.Known)); + Assert.That(cf.WritingSystem, Is.EqualTo("vernacular")); + Assert.That(cf.Visibility, Is.EqualTo(ViewVisibility.Always)); + + var bib = model.Roots[1]; + Assert.That(bib.Visibility, Is.EqualTo(ViewVisibility.IfData), "caller visibility overrides"); + } + + [Test] + public void Import_Snapshot_IsDeterministic() + { + const string layout = @" + + + +"; + + var first = Import(layout).ToSnapshot(); + var second = Import(layout).ToSnapshot(); + + Assert.That(second, Is.EqualTo(first), "import snapshot must be deterministic"); + Assert.That(first, Does.Contain( + "LexEntry/CfAndBib/#0 | Field | label=CitationForm | field=CitationForm | editor=multistring(Known)")); + Assert.That(first, Does.Contain("vis=IfData")); + } + + [Test] + public void Import_NestedGrouping_ProducesGroupWithChildren() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots.Count, Is.EqualTo(1)); + var header = model.Roots[0]; + Assert.That(header.Kind, Is.EqualTo(ViewNodeKind.Group)); + Assert.That(header.Expansion, Is.EqualTo(ViewExpansion.Expanded)); + Assert.That(header.Children.Count, Is.EqualTo(2)); + Assert.That(header.Children[0].StableId, Is.EqualTo("LexEntry/Nested-Expanded/#0/#0")); + Assert.That(header.Children[0].Field, Is.EqualTo("CitationForm")); + Assert.That(header.Children[1].Field, Is.EqualTo("Bibliography")); + } + + [Test] + public void Import_SequenceAndCustomFieldPlaceholder() + { + var model = Import(@" + + + + +"); + + Assert.That(model.Roots.Count, Is.EqualTo(3)); + + var senses = model.Roots[1]; + Assert.That(senses.Kind, Is.EqualTo(ViewNodeKind.Sequence)); + Assert.That(senses.Field, Is.EqualTo("Senses")); + Assert.That(senses.TargetLayout, Is.EqualTo("GlossSn"), "param supplies the item layout"); + Assert.That(senses.Expansion, Is.EqualTo(ViewExpansion.Expanded)); + + var placeholder = model.Roots[2]; + Assert.That(placeholder.Kind, Is.EqualTo(ViewNodeKind.CustomFieldPlaceholder)); + } + + [Test] + public void Import_DynamicEditor_RaisesInfoDiagnostic() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots[0].EditorClassification, Is.EqualTo(EditorClassification.Dynamic)); + Assert.That(model.Diagnostics.Any(d => d.Code == "dynamic-editor"), Is.True); + } + + [Test] + public void Import_UnknownEditor_RaisesWarningDiagnostic() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots[0].EditorClassification, Is.EqualTo(EditorClassification.Unknown)); + var diag = model.Diagnostics.Single(d => d.Code == "unknown-editor"); + Assert.That(diag.Severity, Is.EqualTo(ViewDiagnosticSeverity.Warning)); + Assert.That(diag.NodePath, Is.EqualTo("LexEntry/Weird/#0")); + } + + [Test] + public void Import_ObsoleteEditor_RaisesErrorDiagnostic() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots[0].EditorClassification, Is.EqualTo(EditorClassification.Obsolete)); + Assert.That(model.Diagnostics.Single(d => d.Code == "obsolete-editor").Severity, + Is.EqualTo(ViewDiagnosticSeverity.Error)); + } + + [Test] + public void Import_UnresolvedPart_RaisesErrorDiagnostic_AndDoesNotThrow() + { + var model = Import(@" + + +"); + + Assert.That(model.Roots, Is.Empty); + Assert.That(model.Diagnostics.Single().Code, Is.EqualTo("unresolved-part")); + } + } + + [TestFixture] + public class ViewDefinitionCompilerTests + { + private const string PartsXml = + "" + + "" + + ""; + + private static ViewDefinitionSourceSnapshot Snapshot(string layoutName, string partsXml = PartsXml) + => new ViewDefinitionSourceSnapshot( + "LexEntry", + "detail", + $"", + partsXml); + + [Test] + public void Compile_CachesByFingerprint_ReturnsSameInstance() + { + var compiler = new ViewDefinitionCompiler(); + var snap = Snapshot("CfOnly"); + + var a = compiler.Compile(snap); + var b = compiler.Compile(Snapshot("CfOnly")); + + Assert.That(ReferenceEquals(a, b), Is.True, "identical source should hit the cache"); + Assert.That(compiler.Cache.Count, Is.EqualTo(1)); + } + + [Test] + public void Invalidate_ForcesRecompile() + { + var compiler = new ViewDefinitionCompiler(); + var snap = Snapshot("CfOnly"); + var a = compiler.Compile(snap); + + compiler.Cache.Invalidate(snap.ToKey()); + var b = compiler.Compile(snap); + + Assert.That(ReferenceEquals(a, b), Is.False, "after invalidation a fresh instance is compiled"); + } + + [Test] + public void DifferentSource_ProducesDifferentKey_AndRecompiles() + { + var compiler = new ViewDefinitionCompiler(); + compiler.Compile(Snapshot("CfOnly")); + compiler.Compile(Snapshot("CfOther")); + + Assert.That(compiler.Cache.Count, Is.EqualTo(2)); + Assert.That(Snapshot("CfOnly").ToKey(), Is.Not.EqualTo(Snapshot("CfOther").ToKey())); + } + + [Test] + public void SameSource_ProducesEqualKeys() + { + Assert.That(Snapshot("CfOnly").ToKey(), Is.EqualTo(Snapshot("CfOnly").ToKey())); + Assert.That(Snapshot("CfOnly").ToKey().GetHashCode(), Is.EqualTo(Snapshot("CfOnly").ToKey().GetHashCode())); + } + + [Test] + public async System.Threading.Tasks.Task CompileAsync_MatchesSync_OverImmutableSnapshot() + { + var compiler = new ViewDefinitionCompiler(); + var snap = Snapshot("CfOnly"); + + var sync = compiler.Compile(snap); + var async = await compiler.CompileAsync(Snapshot("CfOnly"), CancellationToken.None); + + Assert.That(async.ToSnapshot(), Is.EqualTo(sync.ToSnapshot())); + } + + [Test] + public void CompileAsync_HonorsCancellation() + { + var compiler = new ViewDefinitionCompiler(); + using (var cts = new CancellationTokenSource()) + { + cts.Cancel(); + Assert.That(async () => await compiler.CompileAsync(Snapshot("CfOnly"), cts.Token), + Throws.InstanceOf()); + } + } + } +} diff --git a/Src/Common/FwAvalonia/LexicalEditSurfaceFactory.cs b/Src/Common/FwAvalonia/LexicalEditSurfaceFactory.cs new file mode 100644 index 0000000000..5d9b16b17a --- /dev/null +++ b/Src/Common/FwAvalonia/LexicalEditSurfaceFactory.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Constructs the Lexical Edit surface for a host, proving the key dual-run property: + /// when the flag is off, the Avalonia surface (and therefore the Avalonia runtime) is + /// never constructed. The Avalonia builder is supplied as a delegate so this factory + /// itself carries no Avalonia dependency and can be tested in isolation. + /// + public sealed class LexicalEditSurfaceFactory + { + private readonly Func _winFormsSurfaceBuilder; + private readonly Func _avaloniaSurfaceBuilder; + + /// + /// Number of times the Avalonia builder has been invoked. Tests assert this stays 0 + /// when the resolved surface is WinForms. + /// + public int AvaloniaConstructionCount { get; private set; } + + public LexicalEditSurfaceFactory( + Func winFormsSurfaceBuilder, + Func avaloniaSurfaceBuilder) + { + _winFormsSurfaceBuilder = winFormsSurfaceBuilder + ?? throw new ArgumentNullException(nameof(winFormsSurfaceBuilder)); + _avaloniaSurfaceBuilder = avaloniaSurfaceBuilder + ?? throw new ArgumentNullException(nameof(avaloniaSurfaceBuilder)); + } + + /// + /// Builds the surface for the given resolution. The Avalonia builder is invoked only + /// when is . + /// + public object Create(LexicalEditSurface surface) + { + if (surface == LexicalEditSurface.Avalonia) + { + AvaloniaConstructionCount++; + return _avaloniaSurfaceBuilder(); + } + + return _winFormsSurfaceBuilder(); + } + } +} diff --git a/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs b/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs new file mode 100644 index 0000000000..b6142cf424 --- /dev/null +++ b/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs @@ -0,0 +1,83 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Which implementation renders the Lexical Edit surface. WinForms is the safe default; + /// Avalonia is the proof-of-concept path selected only when the feature flag is enabled. + /// + public enum LexicalEditSurface + { + /// The existing WinForms DataTree/Slice surface (default). + WinForms, + + /// The Avalonia proof-of-concept surface (flag-gated). + Avalonia + } + + /// + /// Pure-logic resolver for the two-adapter feature flag described in + /// lexical-edit-avalonia-poc-spike. Default is WinForms; Avalonia is selected only when + /// an explicit override or the environment variable opts in. + /// This type has no Avalonia dependency so it can be unit tested without a UI runtime. + /// + public static class LexicalEditSurfaceResolver + { + /// Environment variable that enables the Avalonia POC surface. + public const string FlagEnvVar = "FW_AVALONIA_LEXEDIT"; + /// Property/app-setting key storing the preferred lexical-edit UI mode. + public const string UIModePropertyName = "UIMode"; + public const string LegacyUIMode = "Legacy"; + public const string NewUIMode = "New"; + + /// + /// Resolves the surface to use. Resolution order: an explicit + /// wins; otherwise a truthy environment variable still forces Avalonia for developer/testing + /// scenarios; otherwise the persisted user preference is used. + /// + /// Optional environment reader (defaults to the process environment). + /// Optional strong override (PropertyTable/registry). + /// Persisted user preference (`Legacy` or `New`). + public static LexicalEditSurface Resolve( + Func envReader = null, + bool? overrideEnabled = null, + string uiMode = null) + { + if (overrideEnabled.HasValue) + { + return overrideEnabled.Value ? LexicalEditSurface.Avalonia : LexicalEditSurface.WinForms; + } + + var read = envReader ?? Environment.GetEnvironmentVariable; + if (IsTruthy(read(FlagEnvVar))) + { + return LexicalEditSurface.Avalonia; + } + + return string.Equals(uiMode, NewUIMode, StringComparison.OrdinalIgnoreCase) + ? LexicalEditSurface.Avalonia + : LexicalEditSurface.WinForms; + } + + public static string ToUIModeValue(LexicalEditSurface surface) + => surface == LexicalEditSurface.Avalonia ? NewUIMode : LegacyUIMode; + + private static bool IsTruthy(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + var v = value.Trim(); + return v == "1" + || v.Equals("true", StringComparison.OrdinalIgnoreCase) + || v.Equals("on", StringComparison.OrdinalIgnoreCase) + || v.Equals("yes", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/MorphTypePopupChooser.cs b/Src/Common/FwAvalonia/Poc/MorphTypePopupChooser.cs new file mode 100644 index 0000000000..f3a967ae7b --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/MorphTypePopupChooser.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Layout; +using Avalonia.Automation; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// A morph-type chooser rendered as a button that opens a popup (Avalonia flyout) with the + /// available options. Choosing an option updates the bound entry and returns focus to the + /// button (the popup-focus-return behavior the spike must prove). Built in pure C# (no XAML). + /// + public sealed class MorphTypePopupChooser : UserControl + { + private readonly PocEntryDto _entry; + private readonly Button _button; + private readonly Flyout _flyout; + private readonly ListBox _list; + + public MorphTypePopupChooser(PocEntryDto entry) + { + Name = "MorphTypeChooser"; + _entry = entry; + + _list = new ListBox + { + ItemsSource = entry.MorphTypeOptions, + SelectedItem = entry.SelectedMorphType + }; + AutomationProperties.SetAutomationId(_list, "MorphTypeChooser.List"); + AutomationProperties.SetName(_list, "Morph type options"); + _list.SelectionChanged += (sender, args) => + { + if (_list.SelectedItem is MorphTypeOption option) + { + Select(option); + } + }; + + _flyout = new Flyout { Content = _list }; + + _button = new Button + { + Name = "MorphTypeButton", + Content = DisplayText(entry.SelectedMorphType), + Padding = PocDensity.EditorPadding, + MinHeight = 0, + HorizontalAlignment = HorizontalAlignment.Left, + Flyout = _flyout + }; + AutomationProperties.SetAutomationId(_button, "MorphTypeChooser.Button"); + AutomationProperties.SetName(_button, "Morph Type"); + + Content = _button; + } + + /// The button that launches the chooser popup. + public Button Button => _button; + + /// The chooser popup flyout. + public Flyout Flyout => _flyout; + + /// Opens the chooser popup. + public void Open() => _flyout.ShowAt(_button); + + /// + /// Selects an option: updates the bound entry and button label, closes the popup, and + /// returns focus to the button. Exposed so headless tests can drive selection deterministically. + /// + public void Select(MorphTypeOption option) + { + if (option == null) + { + return; + } + + _entry.MorphTypeKey = option.Key; + _button.Content = DisplayText(option); + _flyout.Hide(); + _button.Focus(); + } + + private static string DisplayText(MorphTypeOption option) + => option != null ? option.Name : "(choose)"; + } +} diff --git a/Src/Common/FwAvalonia/Poc/MultiWsTextEditor.cs b/Src/Common/FwAvalonia/Poc/MultiWsTextEditor.cs new file mode 100644 index 0000000000..f6460b1319 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/MultiWsTextEditor.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// A dense, multi-writing-system text editor: one row per writing-system alternative, each with + /// a small abbreviation gutter and a text box that uses that writing system's configured font. + /// Edits write straight back to the bound values. Built in pure C# + /// (no XAML) so the spike does not depend on the Avalonia XAML compiler. + /// + public sealed class MultiWsTextEditor : UserControl + { + private readonly List _boxes = new List(); + + public MultiWsTextEditor(IList alternatives, string editorName) + { + Name = editorName; + AutomationProperties.SetAutomationId(this, editorName); + AutomationProperties.SetName(this, editorName); + Alternatives = alternatives; + + var stack = new StackPanel { Spacing = PocDensity.RowSpacing }; + + foreach (var alt in alternatives) + { + var captured = alt; + + var abbrev = new TextBlock + { + Text = alt.WsAbbrev, + Width = PocDensity.WsAbbrevWidth, + VerticalAlignment = VerticalAlignment.Center, + Foreground = Brushes.Gray + }; + + var box = new TextBox + { + Text = alt.Value, + FontFamily = new FontFamily(alt.FontFamily), + FontSize = alt.FontSize, + Padding = PocDensity.EditorPadding, + MinHeight = 0, + AcceptsReturn = false + }; + AutomationProperties.SetAutomationId(box, editorName + "." + alt.WsAbbrev); + AutomationProperties.SetName(box, editorName + " " + alt.WsAbbrev); + box.TextChanged += (sender, args) => captured.Value = box.Text; + _boxes.Add(box); + + var row = new DockPanel(); + DockPanel.SetDock(abbrev, Dock.Left); + row.Children.Add(abbrev); + row.Children.Add(box); + + stack.Children.Add(row); + } + + Content = stack; + } + + /// The writing-system alternatives this editor is bound to. + public IList Alternatives { get; } + + /// The text boxes, one per writing-system alternative, in order. + public IReadOnlyList Boxes => _boxes; + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocApp.cs b/Src/Common/FwAvalonia/Poc/PocApp.cs new file mode 100644 index 0000000000..31e90762b0 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocApp.cs @@ -0,0 +1,38 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia; +using Avalonia.Themes.Fluent; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// Minimal Avalonia for the POC. Adds the Fluent theme so the + /// pure-C# controls receive templates both in the Preview Host and in headless tests. + /// + public sealed class PocApp : Application + { + public override void Initialize() + { + Styles.Add(new FluentTheme()); + } + } + + /// + /// AppBuilder configuration for the POC. Desktop/Win32 platform detection is used for the + /// in-process embedding path beside WinForms; headless tests configure their own platform. + /// + public static class PocAvaloniaHost + { + /// Builds the AppBuilder for desktop (Win32) hosting. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + + /// Creates the POC slice control for the given entry (the embeddable Avalonia content). + public static PocLexEntrySlice CreateSlice(PocEntryDto entry) + => new PocLexEntrySlice(entry); + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocDensity.cs b/Src/Common/FwAvalonia/Poc/PocDensity.cs new file mode 100644 index 0000000000..f046eb8d04 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocDensity.cs @@ -0,0 +1,33 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// Density tokens chosen to match the compact WinForms DataTree baseline (the "density" + /// half of the fidelity question). Centralized so the parity comparison can tune one place. + /// + public static class PocDensity + { + /// Width of the field label column. + public const double LabelColumnWidth = 96d; + + /// Width of the small writing-system abbreviation gutter. + public const double WsAbbrevWidth = 28d; + + /// Vertical spacing between writing-system rows within a field. + public const double RowSpacing = 1d; + + /// Vertical spacing between fields. + public const double FieldSpacing = 2d; + + /// Compact padding inside text editors. + public static readonly Thickness EditorPadding = new Thickness(3, 1, 3, 1); + + /// Compact margin around the slice. + public static readonly Thickness SliceMargin = new Thickness(4, 2, 4, 2); + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocEditSession.cs b/Src/Common/FwAvalonia/Poc/PocEditSession.cs new file mode 100644 index 0000000000..cb8451c0b4 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocEditSession.cs @@ -0,0 +1,60 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Seams; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// A POC stand-in for the fenced LCModel edit session (see avalonia-edit-sessions). It captures + /// the entry's editable values on construction; Commit keeps the edits, Cancel restores the + /// captured snapshot. The product implementation will fence a real LCModel undo task; the POC + /// proves the commit/cancel boundary semantics that the editors must drive. + /// + public sealed class PocEditSession : IEditSession + { + private readonly PocEntryDto _entry; + private readonly Dictionary _originalText = new Dictionary(); + private readonly string _originalMorphTypeKey; + private bool _closed; + + public PocEditSession(PocEntryDto entry) + { + _entry = entry; + _originalMorphTypeKey = entry.MorphTypeKey; + Snapshot(entry.LexemeForm); + Snapshot(entry.SenseGloss); + } + + private void Snapshot(IEnumerable alternatives) + { + foreach (var alt in alternatives) + { + _originalText[alt] = alt.Value; + } + } + + /// Whether the session is still open for edits. + public bool IsOpen => !_closed; + + /// Commits the edits (the values already live on the DTO) and closes the session. + public void Commit() + { + _closed = true; + } + + /// Restores the captured snapshot and closes the session. + public void Cancel() + { + foreach (var pair in _originalText) + { + pair.Key.Value = pair.Value; + } + + _entry.MorphTypeKey = _originalMorphTypeKey; + _closed = true; + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocEntryDto.cs b/Src/Common/FwAvalonia/Poc/PocEntryDto.cs new file mode 100644 index 0000000000..0c3ab61cce --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocEntryDto.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// A single writing-system alternative for a multi-string field. Carries the per-writing-system + /// font settings the Avalonia editor must honor (the fidelity question this spike answers). + /// Detached from LCModel so the POC can run in the Preview Host and headless tests. + /// + public sealed class WsAlternative + { + public WsAlternative(string wsAbbrev, string value, string fontFamily = "Charis SIL", double fontSize = 12d) + { + WsAbbrev = wsAbbrev; + Value = value; + FontFamily = fontFamily; + FontSize = fontSize; + } + + /// Short writing-system label shown in the dense per-alternative row (e.g. "en", "seh"). + public string WsAbbrev { get; } + + /// Configured font family for this writing system. + public string FontFamily { get; } + + /// Configured font size for this writing system. + public double FontSize { get; } + + /// The editable text value. + public string Value { get; set; } + } + + /// A selectable morph type option for the chooser popup. + public sealed class MorphTypeOption + { + public MorphTypeOption(string key, string name) + { + Key = key; + Name = name; + } + + public string Key { get; } + + public string Name { get; } + + public override string ToString() => Name; + } + + /// + /// Minimal representative slice of a lexical entry: a multi-writing-system lexeme form, + /// a morph type selection, and one multi-writing-system sense gloss. These three cover the + /// dominant Lexical Edit interaction classes (dense WS text + chooser flyout) for the spike. + /// + public sealed class PocEntryDto + { + public PocEntryDto( + IList lexemeForm, + IList morphTypeOptions, + string morphTypeKey, + IList senseGloss) + { + LexemeForm = lexemeForm; + MorphTypeOptions = morphTypeOptions; + MorphTypeKey = morphTypeKey; + SenseGloss = senseGloss; + } + + public IList LexemeForm { get; } + + public IList MorphTypeOptions { get; } + + public string MorphTypeKey { get; set; } + + public IList SenseGloss { get; } + + /// The currently selected morph type option, or null. + public MorphTypeOption SelectedMorphType + { + get + { + foreach (var option in MorphTypeOptions) + { + if (option.Key == MorphTypeKey) + { + return option; + } + } + + return null; + } + } + + /// Builds a representative sample entry for preview/headless scenarios. + public static PocEntryDto CreateSample() + { + var lexemeForm = new List + { + new WsAlternative("seh", "kazi"), + new WsAlternative("en", "work", "Times New Roman") + }; + + var morphTypes = new List + { + new MorphTypeOption("stem", "stem"), + new MorphTypeOption("root", "root"), + new MorphTypeOption("prefix", "prefix"), + new MorphTypeOption("suffix", "suffix") + }; + + var gloss = new List + { + new WsAlternative("en", "to work", "Times New Roman"), + new WsAlternative("pt", "trabalhar", "Times New Roman") + }; + + return new PocEntryDto(lexemeForm, morphTypes, "stem", gloss); + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocLexEntrySlice.cs b/Src/Common/FwAvalonia/Poc/PocLexEntrySlice.cs new file mode 100644 index 0000000000..0826e298a3 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocLexEntrySlice.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Automation; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// The Avalonia proof-of-concept Lexical Edit slice: a dense, three-field surface + /// (multi-writing-system lexeme form, morph-type chooser, multi-writing-system sense gloss) + /// over a detached . This is the candidate compared against the + /// WinForms DataTree baseline for semantic + density parity. Built in pure C# (no XAML). + /// + public sealed class PocLexEntrySlice : UserControl + { + public PocLexEntrySlice(PocEntryDto entry) + { + Name = "PocLexEntrySlice"; + AutomationProperties.SetAutomationId(this, "PocLexEntrySlice"); + AutomationProperties.SetName(this, "Lexical Edit POC Slice"); + Entry = entry; + + LexemeFormEditor = new MultiWsTextEditor(entry.LexemeForm, "LexemeFormEditor"); + MorphTypeChooser = new MorphTypePopupChooser(entry); + SenseGlossEditor = new MultiWsTextEditor(entry.SenseGloss, "SenseGlossEditor"); + + var grid = new Grid + { + Margin = PocDensity.SliceMargin, + ColumnDefinitions = new ColumnDefinitions + { + new ColumnDefinition(PocDensity.LabelColumnWidth, GridUnitType.Pixel), + new ColumnDefinition(GridLength.Star) + }, + RowDefinitions = new RowDefinitions + { + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto), + new RowDefinition(GridLength.Auto) + } + }; + + AddField(grid, 0, "Lexeme Form", LexemeFormEditor); + AddField(grid, 1, "Morph Type", MorphTypeChooser); + AddField(grid, 2, "Gloss", SenseGlossEditor); + + Content = grid; + } + + /// The bound entry. + public PocEntryDto Entry { get; } + + /// The lexeme-form multi-writing-system editor. + public MultiWsTextEditor LexemeFormEditor { get; } + + /// The morph-type chooser. + public MorphTypePopupChooser MorphTypeChooser { get; } + + /// The sense-gloss multi-writing-system editor. + public MultiWsTextEditor SenseGlossEditor { get; } + + private static void AddField(Grid grid, int row, string label, Control editor) + { + var labelBlock = new TextBlock + { + Text = label, + Margin = new Avalonia.Thickness(0, 0, 6, PocDensity.FieldSpacing), + VerticalAlignment = VerticalAlignment.Top, + TextAlignment = TextAlignment.Right, + Foreground = Brushes.Black + }; + AutomationProperties.SetAutomationId(labelBlock, editor.Name + ".Label"); + AutomationProperties.SetName(labelBlock, label); + Grid.SetRow(labelBlock, row); + Grid.SetColumn(labelBlock, 0); + grid.Children.Add(labelBlock); + + editor.Margin = new Avalonia.Thickness(0, 0, 0, PocDensity.FieldSpacing); + Grid.SetRow(editor, row); + Grid.SetColumn(editor, 1); + grid.Children.Add(editor); + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocPreviewDataProvider.cs b/Src/Common/FwAvalonia/Poc/PocPreviewDataProvider.cs new file mode 100644 index 0000000000..04cefbc28b --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocPreviewDataProvider.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Preview; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// Preview/sample data provider for the lexical-edit POC window. Keeps the preview host detached + /// from LCModel by returning DTO/sample data only. + /// + public sealed class PocPreviewDataProvider : IFwPreviewDataProvider + { + public object CreateDataContext(string dataMode) + { + if (string.Equals(dataMode, "sample", System.StringComparison.OrdinalIgnoreCase)) + { + return PocEntryDto.CreateSample(); + } + + return new PocEntryDto( + new List + { + new WsAlternative("seh", string.Empty), + new WsAlternative("en", string.Empty, "Times New Roman") + }, + new List + { + new MorphTypeOption("stem", "stem"), + new MorphTypeOption("root", "root"), + new MorphTypeOption("prefix", "prefix"), + new MorphTypeOption("suffix", "suffix") + }, + "stem", + new List + { + new WsAlternative("en", string.Empty, "Times New Roman"), + new WsAlternative("pt", string.Empty, "Times New Roman") + }); + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocPreviewWindow.cs b/Src/Common/FwAvalonia/Poc/PocPreviewWindow.cs new file mode 100644 index 0000000000..e3e612375c --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocPreviewWindow.cs @@ -0,0 +1,37 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using Avalonia.Automation; +using Avalonia.Controls; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// Net48 preview-host window for the lexical-edit POC slice. The host sets the DataContext from + /// ; this window responds by creating a fresh slice for that + /// data and exposing stable automation identifiers for UIA tests. + /// + public sealed class PocPreviewWindow : Window + { + public PocPreviewWindow() + { + Width = 900; + Height = 520; + AutomationProperties.SetAutomationId(this, "LexicalEditPocWindow"); + AutomationProperties.SetName(this, "Lexical Edit POC Preview"); + + var empty = new PocLexEntrySlice(PocEntryDto.CreateSample()); + Content = empty; + } + + protected override void OnDataContextChanged(System.EventArgs e) + { + base.OnDataContextChanged(e); + if (DataContext is PocEntryDto entry) + { + Content = new PocLexEntrySlice(entry); + } + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs new file mode 100644 index 0000000000..948854cb30 --- /dev/null +++ b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Windows.Forms; +using Avalonia.Win32.Interoperability; + +namespace SIL.FieldWorks.Common.FwAvalonia.Poc +{ + /// + /// WinForms wrapper that hosts the Avalonia lexical-edit POC slice inside the product app via + /// . This is the in-process net48 path used by the + /// feature-flagged RecordEditView integration. + /// + public sealed class PocWinFormsHostControl : System.Windows.Forms.UserControl + { + private static readonly object s_initGate = new object(); + private static bool s_isAvaloniaInitialized; + + private readonly WinFormsAvaloniaControlHost _host; + + public PocWinFormsHostControl() + { + EnsureAvaloniaInitialized(); + + Name = "PocWinFormsHostControl"; + AccessibleName = "RecordEditView.AvaloniaPoc"; + Dock = DockStyle.Fill; + TabStop = true; + + _host = new WinFormsAvaloniaControlHost + { + Dock = DockStyle.Fill, + Name = "AvaloniaHost", + AccessibleName = "Avalonia Host" + }; + + Controls.Add(_host); + Clear(); + } + + /// Displays the given lexical-entry DTO in the Avalonia POC slice. + public void ShowEntry(PocEntryDto entry) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + _host.Content = new PocLexEntrySlice(entry); + Show(); + } + + /// Displays a simple placeholder message instead of a slice. + public void ShowMessage(string message) + { + _host.Content = new Avalonia.Controls.TextBlock { Text = message ?? string.Empty }; + Show(); + } + + /// Clears the current slice and shows a minimal placeholder. + public void Clear() + { + ShowMessage("No lexical entry selected."); + } + + private static void EnsureAvaloniaInitialized() + { + if (s_isAvaloniaInitialized) + return; + + lock (s_initGate) + { + if (s_isAvaloniaInitialized) + return; + + PocAvaloniaHost.BuildAvaloniaApp().SetupWithoutStarting(); + s_isAvaloniaInitialized = true; + } + } + } +} diff --git a/Src/Common/FwAvalonia/Preview/AssemblyPreviewModules.cs b/Src/Common/FwAvalonia/Preview/AssemblyPreviewModules.cs new file mode 100644 index 0000000000..18ea6166d1 --- /dev/null +++ b/Src/Common/FwAvalonia/Preview/AssemblyPreviewModules.cs @@ -0,0 +1,8 @@ +using SIL.FieldWorks.Common.FwAvalonia.Poc; +using SIL.FieldWorks.Common.FwAvalonia.Preview; + +[assembly: FwPreviewModule( + "lexical-edit-poc", + "Lexical Edit POC", + typeof(PocPreviewWindow), + typeof(PocPreviewDataProvider))] diff --git a/Src/Common/FwAvalonia/Preview/FwPreviewModuleAttribute.cs b/Src/Common/FwAvalonia/Preview/FwPreviewModuleAttribute.cs new file mode 100644 index 0000000000..459d1822ad --- /dev/null +++ b/Src/Common/FwAvalonia/Preview/FwPreviewModuleAttribute.cs @@ -0,0 +1,34 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia.Preview +{ + /// + /// Registers a previewable Avalonia module with the shared preview host. + /// Applied at the assembly level in the module assembly. + /// + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)] + public sealed class FwPreviewModuleAttribute : Attribute + { + public FwPreviewModuleAttribute(string id, string displayName, Type windowType) + : this(id, displayName, windowType, dataProviderType: null) + { + } + + public FwPreviewModuleAttribute(string id, string displayName, Type windowType, Type dataProviderType) + { + Id = id; + DisplayName = displayName; + WindowType = windowType; + DataProviderType = dataProviderType; + } + + public string Id { get; } + public string DisplayName { get; } + public Type WindowType { get; } + public Type DataProviderType { get; } + } +} diff --git a/Src/Common/FwAvalonia/Preview/IFwPreviewDataProvider.cs b/Src/Common/FwAvalonia/Preview/IFwPreviewDataProvider.cs new file mode 100644 index 0000000000..b9390933ab --- /dev/null +++ b/Src/Common/FwAvalonia/Preview/IFwPreviewDataProvider.cs @@ -0,0 +1,11 @@ +namespace SIL.FieldWorks.Common.FwAvalonia.Preview +{ + /// + /// Provides a design-time / preview data context for an Avalonia module window. + /// Implementations should prefer DTO/sample data and avoid opening live FieldWorks projects. + /// + public interface IFwPreviewDataProvider + { + object CreateDataContext(string dataMode); + } +} diff --git a/Src/Common/FwAvalonia/Seams/ISeams.cs b/Src/Common/FwAvalonia/Seams/ISeams.cs new file mode 100644 index 0000000000..28ade45ed9 --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/ISeams.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// + /// Fenced edit-session boundary (see avalonia-edit-sessions). The product implementation fences a + /// real LCModel undo task; both the legacy adapter and the Avalonia editors drive commit/cancel + /// through this contract. + /// + public interface IEditSession + { + bool IsOpen { get; } + + void Commit(); + + void Cancel(); + } + + /// + /// Refresh policy seam over the legacy DataTree DoNotRefresh/RefreshListNeeded gate + /// (LT-22414). Lets refresh coordination be tested without a WinForms control (task 3.1, 3.2). + /// + public interface ILexicalRefreshCoordinator + { + /// Whether refreshes are currently suspended. + bool IsSuspended { get; } + + /// Whether a refresh was requested while suspended and is still pending. + bool RefreshPending { get; } + + /// Begins suspending refreshes (legacy DoNotRefresh = true). + void BeginSuspend(); + + /// + /// Ends suspension (legacy DoNotRefresh = false). Returns true if a refresh was requested + /// while suspended and should now run. + /// + bool EndSuspend(); + + /// + /// Requests a refresh. Returns true if the refresh should run immediately; false if it was + /// suppressed because refreshes are suspended (and is now pending). + /// + bool RequestRefresh(); + } + + /// + /// Editor-selection seam in front of SliceFactory (task 3.3). Editor keys resolve to a legacy + /// slice handler now and to Avalonia editors later, without the caller knowing which adapter answers. + /// + public interface ILexicalEditorRegistry + { + /// Registers a handler token for an editor key. + void Register(string editorKey, object handler); + + /// Resolves the handler for an editor key, or the fallback handler if none is registered. + object Resolve(string editorKey); + + /// Whether an editor key has a registered handler. + bool IsRegistered(string editorKey); + } + + /// Thin UI-thread scheduling seam (see avalonia-ui-scheduler, task 3.7). + public interface IUiScheduler + { + /// Whether the caller is on the UI thread. + bool IsOnUiThread { get; } + + /// Posts work to run on the UI thread. + void Post(Action action); + } + + /// Region lifetime/disposal seam (see avalonia-lifetime, task 3.7). + public interface IRegionLifetime : IDisposable + { + /// Whether the region has been disposed. + bool IsDisposed { get; } + + /// Registers a disposable to be disposed once when the region is disposed. + void Register(IDisposable disposable); + } + + /// Property/state store seam over the xCore PropertyTable (task 3.1). + public interface IPropertyStateStore + { + bool TryGet(string key, out T value); + + void Set(string key, T value); + + bool Remove(string key); + } + + /// Command-bridge seam over the xCore mediator (task 3.1). Routes command ids to handlers. + public interface IXCoreCommandBridge + { + /// Whether a command id can currently be executed. + bool CanExecute(string commandId); + + /// Executes a command id; returns true if a handler accepted it. + bool Execute(string commandId, object argument = null); + } + + /// Record navigation context seam (task 3.1). Exposes the current record and movement. + public interface IRecordNavigationContext + { + object CurrentRecord { get; } + + bool MoveNext(); + + bool MovePrevious(); + } +} diff --git a/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs b/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs new file mode 100644 index 0000000000..83ea75e3e3 --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs @@ -0,0 +1,118 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// + /// Morph type categories, named to match the MoMorphTypeTags.kguidMorphType* model GUIDs. + /// + public enum MorphTypeKind + { + Root, + Stem, + BoundRoot, + BoundStem, + Particle, + Clitic, + Proclitic, + Enclitic, + Phrase, + DiscontiguousPhrase, + Prefix, + Suffix, + Infix, + Simulfix, + Suprafix, + Circumfix, + PrefixingInterfix, + InfixingInterfix, + SuffixingInterfix + } + + /// + /// Pure humble object extracted from MorphTypeAtomicLauncher (task 3.4): the stem/affix + /// classification and swap data-loss decision, with no WinForms dependency. The stem-type set + /// mirrors MorphTypeAtomicLauncher.IsStemType exactly (bound root/stem, enclitic, particle, + /// proclitic, root, stem, clitic, phrase, discontiguous phrase). Swapping across the stem/affix + /// boundary is what triggers the legacy affix/stem data-loss checks. + /// + public static class MorphTypeSwapLogic + { + private static readonly HashSet StemTypes = new HashSet + { + MorphTypeKind.BoundRoot, + MorphTypeKind.BoundStem, + MorphTypeKind.Enclitic, + MorphTypeKind.Particle, + MorphTypeKind.Proclitic, + MorphTypeKind.Root, + MorphTypeKind.Stem, + MorphTypeKind.Clitic, + MorphTypeKind.Phrase, + MorphTypeKind.DiscontiguousPhrase + }; + + /// True if the morph type is a stem-type (mirrors the legacy IsStemType). + public static bool IsStemType(MorphTypeKind type) => StemTypes.Contains(type); + + /// True if a swap from to crosses the stem/affix boundary. + public static bool WouldCrossStemAffixBoundary(MorphTypeKind from, MorphTypeKind to) + => IsStemType(from) != IsStemType(to); + + /// + /// Classifies a swap into a data-loss risk decision. Crossing the stem/affix boundary risks data + /// loss (the affix-only or stem-only data on the allomorph/MSA cannot survive); same-side swaps do not. + /// + public static MorphSwapDecision Analyze(MorphTypeKind from, MorphTypeKind to) + { + if (from == to) + { + return new MorphSwapDecision(false, MorphSwapDirection.None, "No change."); + } + + if (!WouldCrossStemAffixBoundary(from, to)) + { + return new MorphSwapDecision(false, MorphSwapDirection.None, + "Same side of the stem/affix boundary; no data-loss prompt required."); + } + + if (IsStemType(from)) + { + return new MorphSwapDecision(true, MorphSwapDirection.StemToAffix, + "Changing a stem-type to an affix-type may lose stem/MSA data."); + } + + return new MorphSwapDecision(true, MorphSwapDirection.AffixToStem, + "Changing an affix-type to a stem-type may lose affix/inflection data."); + } + } + + /// Direction of a stem/affix boundary crossing. + public enum MorphSwapDirection + { + None, + StemToAffix, + AffixToStem + } + + /// Result of analyzing a morph type swap. + public sealed class MorphSwapDecision + { + public MorphSwapDecision(bool requiresDataLossPrompt, MorphSwapDirection direction, string reason) + { + RequiresDataLossPrompt = requiresDataLossPrompt; + Direction = direction; + Reason = reason; + } + + /// Whether the legacy data-loss confirmation prompt would be required. + public bool RequiresDataLossPrompt { get; } + + public MorphSwapDirection Direction { get; } + + public string Reason { get; } + } +} diff --git a/Src/Common/FwAvalonia/Seams/RefreshCoordinator.cs b/Src/Common/FwAvalonia/Seams/RefreshCoordinator.cs new file mode 100644 index 0000000000..51b1648ccc --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/RefreshCoordinator.cs @@ -0,0 +1,47 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// + /// Pure implementation of the DataTree DoNotRefresh/RefreshListNeeded gate (LT-22414): + /// while suspended, refresh requests are recorded but not run; ending suspension reports whether a + /// refresh is now due. Re-entrant/nested suspension counting is intentionally simple (single gate), + /// matching the characterization tests that do not lock down nested behavior. + /// + public sealed class RefreshCoordinator : ILexicalRefreshCoordinator + { + public bool IsSuspended { get; private set; } + + public bool RefreshPending { get; private set; } + + public void BeginSuspend() + { + IsSuspended = true; + } + + public bool EndSuspend() + { + IsSuspended = false; + if (RefreshPending) + { + RefreshPending = false; + return true; + } + + return false; + } + + public bool RequestRefresh() + { + if (IsSuspended) + { + RefreshPending = true; + return false; + } + + return true; + } + } +} diff --git a/Src/Common/FwAvalonia/Seams/SeamImplementations.cs b/Src/Common/FwAvalonia/Seams/SeamImplementations.cs new file mode 100644 index 0000000000..02c4a33e36 --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/SeamImplementations.cs @@ -0,0 +1,130 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// + /// Editor registry that resolves editor keys to handler tokens, with an optional fallback used for + /// unregistered keys (the legacy slice path during migration). Framework-neutral: handlers are + /// opaque objects so this can sit in front of either WinForms slices or Avalonia editors. + /// + public sealed class LexicalEditorRegistry : ILexicalEditorRegistry + { + private readonly Dictionary _handlers = new Dictionary(StringComparer.Ordinal); + private readonly object _fallback; + + public LexicalEditorRegistry(object fallbackHandler = null) + { + _fallback = fallbackHandler; + } + + public void Register(string editorKey, object handler) + { + if (string.IsNullOrEmpty(editorKey)) + { + throw new ArgumentException("Editor key is required.", nameof(editorKey)); + } + + _handlers[editorKey] = handler ?? throw new ArgumentNullException(nameof(handler)); + } + + public object Resolve(string editorKey) + { + if (!string.IsNullOrEmpty(editorKey) && _handlers.TryGetValue(editorKey, out var handler)) + { + return handler; + } + + return _fallback; + } + + public bool IsRegistered(string editorKey) + => !string.IsNullOrEmpty(editorKey) && _handlers.ContainsKey(editorKey); + } + + /// + /// In-memory property/state store seam. A faithful stand-in for the xCore PropertyTable surface the + /// editors need (get/set/remove typed values), with no WinForms dependency. + /// + public sealed class InMemoryPropertyStateStore : IPropertyStateStore + { + private readonly Dictionary _values = new Dictionary(StringComparer.Ordinal); + + public bool TryGet(string key, out T value) + { + if (_values.TryGetValue(key, out var raw) && raw is T typed) + { + value = typed; + return true; + } + + value = default; + return false; + } + + public void Set(string key, T value) + { + _values[key] = value; + } + + public bool Remove(string key) => _values.Remove(key); + } + + /// + /// UI scheduler that runs work synchronously. Used by non-view layers in tests and the preview host; + /// the live app supplies an Avalonia-dispatcher-backed scheduler at the view edge. + /// + public sealed class ImmediateUiScheduler : IUiScheduler + { + public bool IsOnUiThread => true; + + public void Post(Action action) => action?.Invoke(); + } + + /// + /// Region lifetime that disposes registered disposables exactly once, in reverse registration order. + /// + public sealed class RegionLifetime : IRegionLifetime + { + private readonly List _disposables = new List(); + + public bool IsDisposed { get; private set; } + + public void Register(IDisposable disposable) + { + if (disposable == null) + { + throw new ArgumentNullException(nameof(disposable)); + } + + if (IsDisposed) + { + // Late registration after disposal: dispose immediately to avoid leaks. + disposable.Dispose(); + return; + } + + _disposables.Add(disposable); + } + + public void Dispose() + { + if (IsDisposed) + { + return; + } + + IsDisposed = true; + for (var i = _disposables.Count - 1; i >= 0; i--) + { + _disposables[i].Dispose(); + } + + _disposables.Clear(); + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/DictionaryPartResolver.cs b/Src/Common/FwAvalonia/ViewDefinition/DictionaryPartResolver.cs new file mode 100644 index 0000000000..3a7941feda --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/DictionaryPartResolver.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// A dictionary-backed built from a parts inventory element + /// (<PartInventory><bin><part id="Class-Type-Ref">...). Resolution tries the + /// exact {class}-{type}-{ref} key first, then falls back to {class}-Detail-{ref}, + /// mirroring how the legacy inventory keys detail parts. + /// + public sealed class DictionaryPartResolver : IPartResolver + { + private readonly Dictionary _partsById = new Dictionary(); + + /// Builds a resolver from a <PartInventory> (or its inner <bin>) element. + public DictionaryPartResolver(XElement partInventory) + { + foreach (var part in partInventory.Descendants("part")) + { + var id = (string)part.Attribute("id"); + if (string.IsNullOrEmpty(id) || _partsById.ContainsKey(id)) + { + continue; + } + + _partsById[id] = part; + } + } + + /// + public XElement ResolvePart(string className, string layoutType, string refName) + { + if (string.IsNullOrEmpty(refName)) + { + return null; + } + + var type = string.IsNullOrEmpty(layoutType) ? "Detail" : layoutType; + if (TryGetContent($"{className}-{type}-{refName}", out var content) + || TryGetContent($"{className}-Detail-{refName}", out content)) + { + return content; + } + + return null; + } + + /// + public XElement ResolvePartByRef(string refName) + { + if (string.IsNullOrEmpty(refName)) + { + return null; + } + + var suffix = $"-{refName}"; + XElement match = null; + foreach (var pair in _partsById) + { + if (!pair.Key.EndsWith(suffix, System.StringComparison.Ordinal)) + { + continue; + } + + foreach (var child in pair.Value.Elements()) + { + if (match != null) + { + // Ambiguous across classes: refuse rather than guess. + return null; + } + + match = child; + break; + } + } + + return match; + } + + private bool TryGetContent(string id, out XElement content) + { + content = null; + if (!_partsById.TryGetValue(id, out var part)) + { + return false; + } + + // The part's content is its first child element (slice/obj/seq). + foreach (var child in part.Elements()) + { + content = child; + return true; + } + + return false; + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs b/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs new file mode 100644 index 0000000000..ff7ff10621 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Classifies a legacy editor string the same way SliceFactory.Create does: a fixed set of + /// known statically-resolved editors, the dynamically loaded constructs, the obsolete ones, a + /// grouping (null) editor, and everything else as unknown. This lets the typed importer raise + /// faithful diagnostics for dynamic/unknown/obsolete editors (tasks 3.8 and 4.4) without + /// constructing any WinForms control. + /// + public static class EditorKindMap + { + // Mirrors the case labels in Src/Common/Controls/DetailControls/SliceFactory.cs. + private static readonly HashSet KnownEditors = new HashSet(StringComparer.Ordinal) + { + "multistring", + "defaultvectorreference", + "defaultvectorreferencedisabled", + "possvectorreference", + "semdomvectorreference", + "string", + "jtview", + "summary", + "enumcombobox", + "referencecombobox", + "typeaheadrefatomic", + "msareferencecombobox", + "lit", + "picture", + "image", + "checkbox", + "checkboxwithrefresh", + "time", + "int", + "integer", + "gendate", + "morphtypeatomicreference", + "atomicreferencepos", + "possatomicreference", + "atomicreferenceposdisabled", + "defaultatomicreference", + "defaultatomicreferencedisabled", + "derivmsareference", + "inflmsareference", + "phoneenvreference", + "sttext", + "ghostvector", + "command" + }; + + private static readonly HashSet DynamicEditors = new HashSet(StringComparer.Ordinal) + { + "custom", + "customwithparams", + "autocustom" + }; + + private static readonly HashSet ObsoleteEditors = new HashSet(StringComparer.Ordinal) + { + "message" + }; + + /// + /// Classifies . A null/empty editor is a grouping node; + /// dynamic/obsolete/known are matched against the legacy sets; anything else is unknown. + /// + public static EditorClassification Classify(string rawEditor) + { + if (string.IsNullOrEmpty(rawEditor)) + { + return EditorClassification.GroupingNone; + } + + if (DynamicEditors.Contains(rawEditor)) + { + return EditorClassification.Dynamic; + } + + if (ObsoleteEditors.Contains(rawEditor)) + { + return EditorClassification.Obsolete; + } + + return KnownEditors.Contains(rawEditor) + ? EditorClassification.Known + : EditorClassification.Unknown; + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/IViewDefinitionImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/IViewDefinitionImporter.cs new file mode 100644 index 0000000000..06da9c5454 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/IViewDefinitionImporter.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Xml.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Resolves a layout <part ref="X"> reference to the part's content element + /// (the <slice>/<obj>/<seq> inside the part). This is the + /// seam over the legacy Inventory part lookup, kept framework-neutral so the importer can + /// be unit tested with inline XML. + /// + public interface IPartResolver + { + /// + /// Returns the content element of the part identified by class/layout-type/ref, or null if + /// the part cannot be resolved. + /// + XElement ResolvePart(string className, string layoutType, string refName); + + /// + /// Returns the content element of a part by its ref name alone, used for caller-injected + /// children under object/sequence nodes whose destination class is not known from XML alone. + /// Returns null if the ref is missing or ambiguous. + /// + XElement ResolvePartByRef(string refName); + } + + /// Imports legacy XML Parts/Layout into the typed . + public interface IViewDefinitionImporter + { + /// + /// Imports a single <layout> element using to resolve + /// part references. Never throws on unsupported constructs; it records diagnostics instead. + /// + ViewDefinitionModel Import(XElement layoutElement, IPartResolver parts); + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCacheKey.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCacheKey.cs new file mode 100644 index 0000000000..92e59e2b77 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCacheKey.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Immutable cache key for a compiled view definition. Includes the class/layout identity plus a + /// content fingerprint of the layout and parts source, so that an edit to the XML source produces a + /// different key (and therefore a recompile) even when the layout name is unchanged. + /// + public sealed class ViewDefinitionCacheKey : IEquatable + { + public ViewDefinitionCacheKey(string className, string layoutName, string layoutType, string sourceFingerprint) + { + ClassName = className ?? ""; + LayoutName = layoutName ?? ""; + LayoutType = layoutType ?? ""; + SourceFingerprint = sourceFingerprint ?? ""; + } + + public string ClassName { get; } + + public string LayoutName { get; } + + public string LayoutType { get; } + + /// A stable hash of the layout + parts source text. + public string SourceFingerprint { get; } + + public bool Equals(ViewDefinitionCacheKey other) + { + if (ReferenceEquals(null, other)) return false; + if (ReferenceEquals(this, other)) return true; + return ClassName == other.ClassName + && LayoutName == other.LayoutName + && LayoutType == other.LayoutType + && SourceFingerprint == other.SourceFingerprint; + } + + public override bool Equals(object obj) => Equals(obj as ViewDefinitionCacheKey); + + public override int GetHashCode() + { + unchecked + { + var hash = 17; + hash = hash * 31 + ClassName.GetHashCode(); + hash = hash * 31 + LayoutName.GetHashCode(); + hash = hash * 31 + LayoutType.GetHashCode(); + hash = hash * 31 + SourceFingerprint.GetHashCode(); + return hash; + } + } + + public override string ToString() + => $"{ClassName}/{LayoutName}/{LayoutType}@{SourceFingerprint}"; + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCompiler.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCompiler.cs new file mode 100644 index 0000000000..bedb022241 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionCompiler.cs @@ -0,0 +1,200 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// An immutable snapshot of the XML source needed to compile one view definition. Taking this + /// snapshot up front (rather than reading live Inventory/PropertyTable state during an + /// off-thread compile) satisfies task 4.6: compilation works from immutable inputs only. + /// + public sealed class ViewDefinitionSourceSnapshot + { + public ViewDefinitionSourceSnapshot(string className, string layoutType, string layoutXml, string partsXml) + { + ClassName = className ?? ""; + LayoutType = string.IsNullOrEmpty(layoutType) ? "detail" : layoutType; + LayoutXml = layoutXml ?? ""; + PartsXml = partsXml ?? ""; + } + + public string ClassName { get; } + + public string LayoutType { get; } + + /// The single <layout> element source. + public string LayoutXml { get; } + + /// The <PartInventory> (or <bin>) source. + public string PartsXml { get; } + + /// The layout name parsed from . + public string LayoutName => (string)XElement.Parse(LayoutXml).Attribute("name") ?? ""; + + /// Computes a stable content fingerprint over the layout and parts source text. + public string ComputeFingerprint() + { + using (var sha = SHA256.Create()) + { + var bytes = Encoding.UTF8.GetBytes(ClassName + "\n" + LayoutType + "\n" + LayoutXml + "\n" + PartsXml); + var hash = sha.ComputeHash(bytes); + var sb = new StringBuilder(hash.Length * 2); + foreach (var b in hash) + { + sb.Append(b.ToString("x2")); + } + + return sb.ToString(); + } + } + + /// Builds the cache key for this snapshot. + public ViewDefinitionCacheKey ToKey() + => new ViewDefinitionCacheKey(ClassName, LayoutName, LayoutType, ComputeFingerprint()); + } + + /// A thread-safe cache of compiled view definitions keyed by content fingerprint. + public interface IViewDefinitionCache + { + bool TryGet(ViewDefinitionCacheKey key, out ViewDefinitionModel model); + + ViewDefinitionModel GetOrAdd(ViewDefinitionCacheKey key, Func factory); + + void Invalidate(ViewDefinitionCacheKey key); + + void InvalidateAll(); + + int Count { get; } + } + + /// Simple thread-safe dictionary-backed cache. + public sealed class ViewDefinitionCache : IViewDefinitionCache + { + private readonly object _gate = new object(); + private readonly Dictionary _map + = new Dictionary(); + + public bool TryGet(ViewDefinitionCacheKey key, out ViewDefinitionModel model) + { + lock (_gate) + { + return _map.TryGetValue(key, out model); + } + } + + public ViewDefinitionModel GetOrAdd(ViewDefinitionCacheKey key, Func factory) + { + lock (_gate) + { + if (_map.TryGetValue(key, out var existing)) + { + return existing; + } + } + + // Compile outside the lock so a slow compile does not block other keys. + var created = factory(); + + lock (_gate) + { + if (_map.TryGetValue(key, out var raced)) + { + return raced; + } + + _map[key] = created; + return created; + } + } + + public void Invalidate(ViewDefinitionCacheKey key) + { + lock (_gate) + { + _map.Remove(key); + } + } + + public void InvalidateAll() + { + lock (_gate) + { + _map.Clear(); + } + } + + public int Count + { + get + { + lock (_gate) + { + return _map.Count; + } + } + } + } + + /// + /// Compiles s into s via + /// the , caching by content fingerprint and supporting cancellable + /// off-thread compilation over immutable snapshots. + /// + public sealed class ViewDefinitionCompiler + { + private readonly IViewDefinitionImporter _importer; + private readonly IViewDefinitionCache _cache; + + public ViewDefinitionCompiler(IViewDefinitionImporter importer = null, IViewDefinitionCache cache = null) + { + _importer = importer ?? new XmlLayoutImporter(); + _cache = cache ?? new ViewDefinitionCache(); + } + + public IViewDefinitionCache Cache => _cache; + + /// Compiles synchronously, returning a cached result when the fingerprint matches. + public ViewDefinitionModel Compile(ViewDefinitionSourceSnapshot snapshot) + { + var key = snapshot.ToKey(); + return _cache.GetOrAdd(key, () => CompileCore(snapshot, CancellationToken.None)); + } + + /// + /// Compiles off-thread over the immutable snapshot. Honors cancellation and returns the cached + /// result when available. + /// + public Task CompileAsync(ViewDefinitionSourceSnapshot snapshot, CancellationToken cancellationToken) + { + var key = snapshot.ToKey(); + if (_cache.TryGet(key, out var cached)) + { + return Task.FromResult(cached); + } + + return Task.Run(() => + { + cancellationToken.ThrowIfCancellationRequested(); + return _cache.GetOrAdd(key, () => CompileCore(snapshot, cancellationToken)); + }, cancellationToken); + } + + private ViewDefinitionModel CompileCore(ViewDefinitionSourceSnapshot snapshot, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var layout = XElement.Parse(snapshot.LayoutXml); + var parts = new DictionaryPartResolver(XElement.Parse(snapshot.PartsXml)); + cancellationToken.ThrowIfCancellationRequested(); + return _importer.Import(layout, parts); + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs new file mode 100644 index 0000000000..528071ebca --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -0,0 +1,258 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Text; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Structural kind of a typed view-definition node. Mirrors the node types produced by the + /// legacy XML Parts/Layout interpretation in SliceFactory/DataTree: + /// a leaf field editor, a grouping header, an atomic object, a sequence, or the custom-field + /// placeholder that the legacy code expands from the model. + /// + public enum ViewNodeKind + { + /// A leaf field bound to an editor (legacy <slice editor=.. field=..>). + Field, + + /// A grouping header with child nodes and no editor (legacy <slice> with children). + Group, + + /// An atomic owned/reference object (legacy <obj field=.. layout=..>). + ObjectAtom, + + /// An owning/reference sequence (legacy <seq field=.. layout=..>). + Sequence, + + /// The custom-field placeholder expanded from the model (legacy customFields="here"). + CustomFieldPlaceholder + } + + /// Field visibility, mirroring the legacy visibility attribute. + public enum ViewVisibility + { + /// Always shown (legacy default / "always"). + Always, + + /// Shown only when the field has data (legacy "ifdata"). + IfData, + + /// Never shown unless the user enables hidden fields (legacy "never"). + Never + } + + /// Expansion state for grouping/sequence nodes, mirroring the legacy expansion attribute. + public enum ViewExpansion + { + /// Not an expandable node. + NotApplicable, + + /// Expandable and currently collapsed. + Collapsed, + + /// Expandable and currently expanded. + Expanded + } + + /// + /// How an editor string is classified. The legacy SliceFactory switch resolves known + /// editors directly, loads dynamic editors via DynamicLoader (custom, + /// customwithparams, autocustom), throws on obsolete ones (message), + /// treats a null editor as a grouping node, and falls back to a placeholder for the rest. + /// The typed importer records the classification instead of constructing a control. + /// + public enum EditorClassification + { + /// A known, statically-resolved editor. + Known, + + /// A grouping node with no editor. + GroupingNone, + + /// A dynamically loaded editor (custom/customwithparams/autocustom). + Dynamic, + + /// An obsolete editor that the legacy code rejects. + Obsolete, + + /// An editor string not recognized by the legacy factory's known set. + Unknown + } + + /// Severity of a view-definition diagnostic. + public enum ViewDiagnosticSeverity + { + Info, + Warning, + Error + } + + /// + /// A diagnostic raised while importing/compiling a view definition. Carries the layout part and + /// node path so unsupported constructs are reported, not silently dropped (task 4.4 / 3.8). + /// + public sealed class ViewDiagnostic + { + public ViewDiagnostic(ViewDiagnosticSeverity severity, string code, string message, string nodePath) + { + Severity = severity; + Code = code; + Message = message; + NodePath = nodePath; + } + + public ViewDiagnosticSeverity Severity { get; } + + /// Stable diagnostic code (e.g. "dynamic-editor", "unknown-editor", "unresolved-part"). + public string Code { get; } + + public string Message { get; } + + /// The stable node path the diagnostic applies to. + public string NodePath { get; } + + public override string ToString() + => $"{Severity}: [{Code}] {Message} ({NodePath})"; + } + + /// + /// An immutable typed view-definition node. This is the framework-neutral migration contract that + /// both the legacy WinForms adapter and the future Avalonia adapter consume instead of raw XML. + /// In the hybrid roadmap this is the typed node that the DataTree region's SliceSpec realizes. + /// + public sealed class ViewNode + { + public ViewNode( + string stableId, + ViewNodeKind kind, + string label, + string abbreviation, + string field, + string rawEditor, + EditorClassification editorClassification, + string writingSystem, + ViewVisibility visibility, + ViewExpansion expansion, + bool indented, + string targetLayout, + IReadOnlyList children) + { + StableId = stableId; + Kind = kind; + Label = label; + Abbreviation = abbreviation; + Field = field; + RawEditor = rawEditor; + EditorClassification = editorClassification; + WritingSystem = writingSystem; + Visibility = visibility; + Expansion = expansion; + Indented = indented; + TargetLayout = targetLayout; + Children = children ?? (IReadOnlyList)Array.Empty(); + } + + /// Deterministic identity derived from the node's path (stable across realizations). + public string StableId { get; } + + public ViewNodeKind Kind { get; } + + public string Label { get; } + + public string Abbreviation { get; } + + public string Field { get; } + + /// The raw legacy editor string, preserved for audit/fallback. + public string RawEditor { get; } + + public EditorClassification EditorClassification { get; } + + public string WritingSystem { get; } + + public ViewVisibility Visibility { get; } + + public ViewExpansion Expansion { get; } + + public bool Indented { get; } + + /// For object/sequence nodes, the destination layout name (deep expansion is deferred). + public string TargetLayout { get; } + + public IReadOnlyList Children { get; } + } + + /// + /// An immutable compiled view definition: the typed node tree imported from XML Parts/Layout, + /// plus any diagnostics raised during import. Produced by IViewDefinitionImporter. + /// + public sealed class ViewDefinitionModel + { + public ViewDefinitionModel( + string className, + string layoutName, + string layoutType, + IReadOnlyList roots, + IReadOnlyList diagnostics) + { + ClassName = className; + LayoutName = layoutName; + LayoutType = layoutType; + Roots = roots ?? (IReadOnlyList)Array.Empty(); + Diagnostics = diagnostics ?? (IReadOnlyList)Array.Empty(); + } + + public string ClassName { get; } + + public string LayoutName { get; } + + public string LayoutType { get; } + + public IReadOnlyList Roots { get; } + + public IReadOnlyList Diagnostics { get; } + + /// + /// Produces a deterministic, normalized snapshot of the typed tree for parity/regression tests. + /// One indented line per node keyed on stable identity, kind, binding, editor classification, + /// writing system, visibility, and expansion — incidental layout noise is intentionally excluded. + /// + public string ToSnapshot() + { + var sb = new StringBuilder(); + sb.AppendLine($"layout class={ClassName} name={LayoutName} type={LayoutType}"); + foreach (var root in Roots) + { + AppendNode(sb, root, 0); + } + + foreach (var diag in Diagnostics.OrderBy(d => d.NodePath, StringComparer.Ordinal) + .ThenBy(d => d.Code, StringComparer.Ordinal)) + { + sb.AppendLine($"diag {diag.Severity} [{diag.Code}] {diag.NodePath}"); + } + + return sb.ToString(); + } + + private static void AppendNode(StringBuilder sb, ViewNode node, int depth) + { + var indent = new string(' ', depth * 2); + sb.AppendLine( + $"{indent}{node.StableId} | {node.Kind} | label={node.Label} | field={node.Field} | " + + $"editor={node.RawEditor}({node.EditorClassification}) | ws={node.WritingSystem} | " + + $"vis={node.Visibility} | exp={node.Expansion} | indent={(node.Indented ? "1" : "0")} | " + + $"target={node.TargetLayout}"); + foreach (var child in node.Children) + { + AppendNode(sb, child, depth + 1); + } + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs new file mode 100644 index 0000000000..1639360161 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -0,0 +1,280 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Xml.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Imports legacy XML Parts/Layout into the typed . The importer + /// mirrors the structural decisions of DataTree/SliceFactory (parts, grouping slices, + /// objects, sequences, indent, custom-field placeholders, visibility/expansion/label overrides) but + /// produces immutable typed nodes and diagnostics instead of WinForms controls. It never throws on + /// unsupported constructs; it records a diagnostic and continues. + /// + public sealed class XmlLayoutImporter : IViewDefinitionImporter + { + /// + public ViewDefinitionModel Import(XElement layoutElement, IPartResolver parts) + { + var className = (string)layoutElement.Attribute("class") ?? ""; + var layoutName = (string)layoutElement.Attribute("name") ?? ""; + var layoutType = (string)layoutElement.Attribute("type") ?? "detail"; + + var diagnostics = new List(); + var roots = new List(); + var basePath = $"{className}/{layoutName}"; + + ProcessContainer(layoutElement.Elements(), parts, className, layoutType, basePath, false, roots, diagnostics); + + return new ViewDefinitionModel(className, layoutName, layoutType, roots, diagnostics); + } + + private void ProcessContainer( + IEnumerable elements, + IPartResolver parts, + string className, + string layoutType, + string parentPath, + bool indented, + List output, + List diagnostics) + { + foreach (var el in elements) + { + switch (el.Name.LocalName) + { + case "part": + ProcessPart(el, parts, className, layoutType, parentPath, indented, output, diagnostics); + break; + case "indent": + var indentAttr = (string)el.Attribute("indent"); + var indentFlag = indentAttr == null || indentAttr != "false"; + ProcessContainer(el.Elements(), parts, className, layoutType, parentPath, indentFlag, output, diagnostics); + break; + default: + diagnostics.Add(new ViewDiagnostic( + ViewDiagnosticSeverity.Warning, + "unknown-container-element", + $"Unsupported layout container element '{el.Name.LocalName}'.", + $"{parentPath}/#{output.Count}")); + break; + } + } + } + + private void ProcessPart( + XElement callerEl, + IPartResolver parts, + string className, + string layoutType, + string parentPath, + bool indented, + List output, + List diagnostics) + { + var stableId = $"{parentPath}/#{output.Count}"; + var refName = (string)callerEl.Attribute("ref"); + + // Custom-field placeholder: or ref="_CustomFieldPlaceholder". + if (callerEl.Attribute("customFields") != null || refName == "_CustomFieldPlaceholder") + { + output.Add(MakeLeaf(stableId, ViewNodeKind.CustomFieldPlaceholder, "(custom fields)", null, + null, null, EditorClassification.GroupingNone, null, ViewVisibility.Always, + ViewExpansion.NotApplicable, indented, null)); + return; + } + + if (string.IsNullOrEmpty(refName)) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "part-without-ref", + "A has neither a 'ref' nor 'customFields' attribute.", stableId)); + return; + } + + var content = parts.ResolvePart(className, layoutType, refName); + if (content == null) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Error, "unresolved-part", + $"Could not resolve part ref '{refName}' for class '{className}'.", stableId)); + return; + } + + var node = BuildNode(content, callerEl, parts, className, layoutType, stableId, indented, diagnostics); + if (node != null) + { + output.Add(node); + } + } + + private ViewNode BuildNode( + XElement contentEl, + XElement callerEl, + IPartResolver parts, + string className, + string layoutType, + string stableId, + bool indented, + List diagnostics) + { + var label = Attr(callerEl, "label") ?? Attr(contentEl, "label"); + var abbreviation = Attr(callerEl, "abbr") ?? Attr(contentEl, "abbr"); + var visibility = ParseVisibility(Attr(callerEl, "visibility") ?? Attr(contentEl, "visibility")); + var expansion = ParseExpansion(Attr(callerEl, "expansion") ?? Attr(contentEl, "expansion")); + var field = Attr(contentEl, "field"); + var ws = Attr(contentEl, "ws"); + + switch (contentEl.Name.LocalName) + { + case "slice": + { + var editor = Attr(contentEl, "editor"); + var classification = EditorKindMap.Classify(editor); + RaiseEditorDiagnostics(editor, classification, stableId, diagnostics); + + var childElements = new List(); + foreach (var child in contentEl.Elements()) + { + if (child.Name.LocalName == "slice" || child.Name.LocalName == "seq" || child.Name.LocalName == "obj") + { + childElements.Add(child); + } + } + + if (classification == EditorClassification.GroupingNone && childElements.Count > 0) + { + var children = new List(); + BuildInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); + return new ViewNode(stableId, ViewNodeKind.Group, label, abbreviation, field, editor, + classification, ws, visibility, expansion, indented, null, children); + } + + return MakeLeaf(stableId, ViewNodeKind.Field, label, abbreviation, field, editor, + classification, ws, visibility, expansion, indented, null); + } + case "obj": + case "seq": + { + var kind = contentEl.Name.LocalName == "obj" ? ViewNodeKind.ObjectAtom : ViewNodeKind.Sequence; + var targetLayout = Attr(callerEl, "param") ?? Attr(contentEl, "layout"); + var children = new List(); + BuildInjectedChildren(callerEl, parts, layoutType, stableId, children, diagnostics); + return new ViewNode(stableId, kind, label, abbreviation, field, null, + EditorClassification.GroupingNone, ws, visibility, expansion, indented, targetLayout, children); + } + default: + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "unknown-part-content", + $"Unsupported part content element '{contentEl.Name.LocalName}'.", stableId)); + return null; + } + } + + // Inline children are concrete // elements nested directly inside a grouping slice. + private void BuildInlineChildren( + IEnumerable childElements, + IPartResolver parts, + string className, + string layoutType, + string parentPath, + List output, + List diagnostics) + { + foreach (var child in childElements) + { + var stableId = $"{parentPath}/#{output.Count}"; + var node = BuildNode(child, child, parts, className, layoutType, stableId, false, diagnostics); + if (node != null) + { + output.Add(node); + } + } + } + + // Caller-injected children are elements nested under a layout's object/sequence part. + // Their destination class is not known from XML alone, so they are resolved by ref name. + private void BuildInjectedChildren( + XElement callerEl, + IPartResolver parts, + string layoutType, + string parentPath, + List output, + List diagnostics) + { + foreach (var child in callerEl.Elements("part")) + { + var stableId = $"{parentPath}/#{output.Count}"; + var refName = (string)child.Attribute("ref"); + if (string.IsNullOrEmpty(refName)) + { + continue; + } + + var content = parts.ResolvePartByRef(refName); + if (content == null) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Info, "cross-object-deferred", + $"Injected child '{refName}' could not be resolved by ref; deep cross-object expansion is deferred.", + stableId)); + continue; + } + + var node = BuildNode(content, child, parts, "", layoutType, stableId, false, diagnostics); + if (node != null) + { + output.Add(node); + } + } + } + + private static void RaiseEditorDiagnostics( + string editor, EditorClassification classification, string stableId, List diagnostics) + { + switch (classification) + { + case EditorClassification.Dynamic: + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Info, "dynamic-editor", + $"Editor '{editor}' is dynamically loaded; it needs an Avalonia editor mapping.", stableId)); + break; + case EditorClassification.Obsolete: + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Error, "obsolete-editor", + $"Editor '{editor}' is obsolete and rejected by the legacy factory.", stableId)); + break; + case EditorClassification.Unknown: + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "unknown-editor", + $"Editor '{editor}' is not in the known editor set.", stableId)); + break; + } + } + + private static ViewNode MakeLeaf( + string stableId, ViewNodeKind kind, string label, string abbreviation, string field, string editor, + EditorClassification classification, string ws, ViewVisibility visibility, ViewExpansion expansion, + bool indented, string targetLayout) + => new ViewNode(stableId, kind, label, abbreviation, field, editor, classification, ws, visibility, + expansion, indented, targetLayout, System.Array.Empty()); + + private static string Attr(XElement el, string name) => (string)el.Attribute(name); + + private static ViewVisibility ParseVisibility(string value) + { + switch (value) + { + case "never": return ViewVisibility.Never; + case "ifdata": return ViewVisibility.IfData; + default: return ViewVisibility.Always; + } + } + + private static ViewExpansion ParseExpansion(string value) + { + switch (value) + { + case "expanded": return ViewExpansion.Expanded; + case "collapsed": return ViewExpansion.Collapsed; + default: return ViewExpansion.NotApplicable; + } + } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj new file mode 100644 index 0000000000..77777c21c2 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj @@ -0,0 +1,35 @@ + + + + FwAvaloniaPreviewHost + SIL.FieldWorks.Common.FwAvalonia.PreviewHost + net48 + latest + WinExe + disable + false + false + false + false + $(NoWarn);CS1591;NU1701 + $(MSBuildThisFileDirectory)bin\$(Configuration)\net48\ + $(OutputPath) + false + + + + + + + + + + + + + + + + + + diff --git a/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj new file mode 100644 index 0000000000..a64732e460 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj @@ -0,0 +1,32 @@ + + + + net48 + latest + disable + true + false + false + false + $(NoWarn);CS1591;NU1701 + $(MSBuildThisFileDirectory)bin\$(Configuration)\net48\ + $(OutputPath) + false + + + + + + + + + + + + + + + + + + diff --git a/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs new file mode 100644 index 0000000000..c9538651d4 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs @@ -0,0 +1,147 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using System.Windows.Automation; +using NUnit.Framework; + +namespace FwAvaloniaPreviewHostTests +{ + /// + /// Native desktop automation (UIA2/System.Windows.Automation) smoke tests for the net48 preview + /// host. These validate the real Windows accessibility tree, not Avalonia.Headless internals. + /// They require an interactive Windows desktop/session and therefore are categorized separately. + /// + [TestFixture] + [Category("UIA")] + [NonParallelizable] + [Apartment(ApartmentState.STA)] + public class PreviewHostUiaTests + { + private Process m_process; + + [TearDown] + public void TearDown() + { + if (m_process == null) + return; + + try + { + if (!m_process.HasExited) + { + m_process.CloseMainWindow(); + if (!m_process.WaitForExit(2000)) + { + m_process.Kill(); + m_process.WaitForExit(2000); + } + } + } + catch + { + } + finally + { + m_process.Dispose(); + m_process = null; + } + } + + [Test] + public void PreviewHost_MainWindowAndCoreControls_ExposeStableAutomationIds() + { + EnsureInteractiveDesktop(); + var window = StartPreviewHostAndWaitForWindow(); + + Assert.That(window.Current.Name, Is.EqualTo("Lexical Edit POC (Preview)")); + Assert.That(FindByAutomationId(window, "LexemeFormEditor.seh"), Is.Not.Null); + Assert.That(FindByAutomationId(window, "LexemeFormEditor.en"), Is.Not.Null); + Assert.That(FindByAutomationId(window, "SenseGlossEditor.en"), Is.Not.Null); + Assert.That(FindByAutomationId(window, "MorphTypeChooser.Button"), Is.Not.Null); + } + + [Test] + public void PreviewHost_MorphTypeButton_Invoke_ShowsPopupList() + { + EnsureInteractiveDesktop(); + var window = StartPreviewHostAndWaitForWindow(); + var button = FindByAutomationId(window, "MorphTypeChooser.Button"); + Assert.That(button, Is.Not.Null, "Morph type button should be reachable in the automation tree."); + + var invoke = button.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern; + Assert.That(invoke, Is.Not.Null, "Morph type button should support InvokePattern."); + invoke.Invoke(); + + var list = WaitForElement( + () => AutomationElement.RootElement.FindFirst( + TreeScope.Subtree, + new PropertyCondition(AutomationElement.AutomationIdProperty, "MorphTypeChooser.List"))); + + Assert.That(list, Is.Not.Null, "Invoking the button should show the popup list."); + var suffix = list.FindFirst( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.NameProperty, "suffix")); + Assert.That(suffix, Is.Not.Null, "Popup should expose the 'suffix' option through UIA."); + } + + private static void EnsureInteractiveDesktop() + { + if (!Environment.UserInteractive) + { + Assert.Ignore("UIA2 preview-host tests require an interactive Windows desktop/session."); + } + } + + private AutomationElement StartPreviewHostAndWaitForWindow() + { + var exePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "FwAvaloniaPreviewHost.exe"); + Assert.That(File.Exists(exePath), Is.True, + "Preview host executable must be built before running UIA2 tests. Expected: " + exePath); + + m_process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = exePath, + Arguments = "--module lexical-edit-poc --data sample", + WorkingDirectory = AppDomain.CurrentDomain.BaseDirectory, + UseShellExecute = false + } + }; + + Assert.That(m_process.Start(), Is.True, "Preview host process should start."); + + return WaitForElement(() => + { + m_process.Refresh(); + if (m_process.HasExited || m_process.MainWindowHandle == IntPtr.Zero) + return null; + return AutomationElement.FromHandle(m_process.MainWindowHandle); + }); + } + + private static AutomationElement FindByAutomationId(AutomationElement root, string automationId) + => root.FindFirst( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.AutomationIdProperty, automationId)); + + private static AutomationElement WaitForElement(Func finder) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (DateTime.UtcNow < deadline) + { + var element = finder(); + if (element != null) + return element; + Thread.Sleep(100); + } + + return null; + } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/ModuleCatalog.cs b/Src/Common/FwAvaloniaPreviewHost/ModuleCatalog.cs new file mode 100644 index 0000000000..3e32c25a10 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/ModuleCatalog.cs @@ -0,0 +1,94 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using SIL.FieldWorks.Common.FwAvalonia.Preview; + +namespace SIL.FieldWorks.Common.FwAvalonia.PreviewHost +{ + internal sealed class ModuleCatalog + { + public ModuleCatalog() + { + Modules = LoadAssemblies(AppContext.BaseDirectory) + .SelectMany(GetModules) + .OrderBy(m => m.Id, StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + public IReadOnlyList Modules { get; } + + public ModuleInfo Find(string id) + { + if (string.IsNullOrWhiteSpace(id)) + return Modules.FirstOrDefault(); + + return Modules.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.OrdinalIgnoreCase)); + } + + private static IEnumerable LoadAssemblies(string directory) + { + var loaded = new List(); + loaded.AddRange(AppDomain.CurrentDomain.GetAssemblies()); + + foreach (var path in Directory.EnumerateFiles(directory, "*.dll")) + { + try + { + var assemblyName = AssemblyName.GetAssemblyName(path); + if (loaded.Any(a => AssemblyName.ReferenceMatchesDefinition(a.GetName(), assemblyName))) + continue; + + loaded.Add(Assembly.LoadFrom(path)); + } + catch + { + // Ignore native DLLs and any load failures. + } + } + + return loaded; + } + + private static IEnumerable GetModules(Assembly assembly) + { + FwPreviewModuleAttribute[] attrs; + try + { + attrs = assembly.GetCustomAttributes(typeof(FwPreviewModuleAttribute), false) + .OfType() + .ToArray(); + } + catch + { + yield break; + } + + foreach (var attr in attrs) + { + yield return new ModuleInfo(attr.Id, attr.DisplayName, attr.WindowType, attr.DataProviderType); + } + } + } + + internal sealed class ModuleInfo + { + public ModuleInfo(string id, string displayName, Type windowType, Type dataProviderType) + { + Id = id; + DisplayName = displayName; + WindowType = windowType; + DataProviderType = dataProviderType; + } + + public string Id { get; } + public string DisplayName { get; } + public Type WindowType { get; } + public Type DataProviderType { get; } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs new file mode 100644 index 0000000000..8c5cae2c4b --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/PreviewHostApp.cs @@ -0,0 +1,104 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Media; +using Avalonia.Themes.Fluent; +using SIL.FieldWorks.Common.FwAvalonia.Preview; + +namespace SIL.FieldWorks.Common.FwAvalonia.PreviewHost +{ + internal sealed class PreviewHostApp : Application + { + public override void Initialize() + { + Styles.Add(new FluentTheme()); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + try + { + var catalog = new ModuleCatalog(); + var options = PreviewOptions.Current; + var module = catalog.Find(options.ModuleId); + if (module == null) + { + throw new InvalidOperationException( + "No preview modules were found. Ensure at least one module assembly is present and declares [assembly: FwPreviewModule(...)]"); + } + + var window = CreateWindow(module, options.DataMode); + desktop.MainWindow = window; + if (!window.IsVisible) + window.Show(); + } + catch (Exception ex) + { + desktop.MainWindow = CreateErrorWindow(ex, PreviewOptions.Current); + if (!desktop.MainWindow.IsVisible) + desktop.MainWindow.Show(); + } + } + + base.OnFrameworkInitializationCompleted(); + } + + private static Window CreateErrorWindow(Exception ex, PreviewOptions options) + { + var message = + "Preview Host failed to start.\n\n" + + "module: " + options.ModuleId + "\n" + + "data: " + options.DataMode + "\n\n" + + "log: " + PreviewHostLogging.LogFilePath + "\n\n" + + ex.ToString(); + + var text = new TextBox + { + Text = message, + IsReadOnly = true, + TextWrapping = TextWrapping.NoWrap, + AcceptsReturn = true + }; + AutomationProperties.SetAutomationId(text, "PreviewHost.ErrorText"); + AutomationProperties.SetName(text, "Preview host error text"); + + var window = new Window + { + Title = "FieldWorks Avalonia Preview Host - Error", + Width = 1000, + Height = 700, + Content = text + }; + AutomationProperties.SetAutomationId(window, "PreviewHost.ErrorWindow"); + AutomationProperties.SetName(window, "FieldWorks Avalonia Preview Host Error"); + return window; + } + + private static Window CreateWindow(ModuleInfo module, string dataMode) + { + var window = Activator.CreateInstance(module.WindowType) as Window; + if (window == null) + throw new InvalidOperationException("Module '" + module.Id + "' window type is not an Avalonia Window: " + module.WindowType.FullName); + + if (module.DataProviderType != null) + { + var provider = Activator.CreateInstance(module.DataProviderType) as IFwPreviewDataProvider; + if (provider != null) + window.DataContext = provider.CreateDataContext(dataMode); + } + + window.Title = module.DisplayName + " (Preview)"; + AutomationProperties.SetAutomationId(window, "FwAvaloniaPreviewHost.MainWindow"); + AutomationProperties.SetName(window, window.Title); + return window; + } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/PreviewHostLogging.cs b/Src/Common/FwAvaloniaPreviewHost/PreviewHostLogging.cs new file mode 100644 index 0000000000..5046c21ce0 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/PreviewHostLogging.cs @@ -0,0 +1,71 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Diagnostics; +using System.IO; + +namespace SIL.FieldWorks.Common.FwAvalonia.PreviewHost +{ + internal static class PreviewHostLogging + { + private const string ListenerName = "FwPreviewHostFile"; + private static bool s_initialized; + + public static string LogFilePath { get; private set; } = string.Empty; + + public static void Initialize() + { + if (s_initialized) + return; + s_initialized = true; + + try + { + var baseDir = AppContext.BaseDirectory; + var configuredPath = Environment.GetEnvironmentVariable("FW_PREVIEW_TRACE_LOG"); + var logPath = string.IsNullOrWhiteSpace(configuredPath) + ? Path.Combine(baseDir, "FieldWorks.trace.log") + : configuredPath; + + var logDir = Path.GetDirectoryName(logPath); + if (!string.IsNullOrWhiteSpace(logDir)) + Directory.CreateDirectory(logDir); + + LogFilePath = logPath; + + if (!HasListener(ListenerName)) + { + var fileStream = new FileStream(logPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite); + var writer = new StreamWriter(fileStream) { AutoFlush = true }; + Trace.Listeners.Add(new TextWriterTraceListener(writer, ListenerName)); + } + + Trace.AutoFlush = true; + Trace.WriteLine($"[FwAvaloniaPreviewHost] Logging initialized. LogFile='{logPath}' BaseDir='{baseDir}'"); + } + catch (Exception ex) + { + try + { + Trace.WriteLine($"[FwAvaloniaPreviewHost] Failed to initialize logging: {ex}"); + } + catch + { + } + } + } + + private static bool HasListener(string name) + { + foreach (TraceListener listener in Trace.Listeners) + { + if (string.Equals(listener.Name, name, StringComparison.Ordinal)) + return true; + } + + return false; + } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/PreviewOptions.cs b/Src/Common/FwAvaloniaPreviewHost/PreviewOptions.cs new file mode 100644 index 0000000000..7a3729ee12 --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/PreviewOptions.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; + +namespace SIL.FieldWorks.Common.FwAvalonia.PreviewHost +{ + internal sealed class PreviewOptions + { + public static PreviewOptions Current { get; set; } = new PreviewOptions(null, "empty"); + + public PreviewOptions(string moduleId, string dataMode) + { + ModuleId = moduleId; + DataMode = string.IsNullOrWhiteSpace(dataMode) ? "empty" : dataMode; + } + + public string ModuleId { get; } + public string DataMode { get; } + + public static PreviewOptions Parse(string[] args) + { + string module = null; + var data = "empty"; + + for (var i = 0; i < args.Length; i++) + { + var arg = args[i]; + if (string.Equals(arg, "--module", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + module = args[++i]; + continue; + } + + if (string.Equals(arg, "--data", StringComparison.OrdinalIgnoreCase) && i + 1 < args.Length) + { + data = args[++i]; + } + } + + return new PreviewOptions(module, data); + } + } +} diff --git a/Src/Common/FwAvaloniaPreviewHost/Program.cs b/Src/Common/FwAvaloniaPreviewHost/Program.cs new file mode 100644 index 0000000000..fc4850c88b --- /dev/null +++ b/Src/Common/FwAvaloniaPreviewHost/Program.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Diagnostics; +using System.Threading.Tasks; +using Avalonia; + +namespace SIL.FieldWorks.Common.FwAvalonia.PreviewHost +{ + internal static class Program + { + [STAThread] + public static void Main(string[] args) + { + AppDomain.CurrentDomain.UnhandledException += (sender, e) => + { + try + { + Trace.TraceError("[FwAvaloniaPreviewHost] Unhandled exception (terminating=" + e.IsTerminating + ")." + Environment.NewLine + e.ExceptionObject); + Trace.Flush(); + } + catch + { + } + }; + + TaskScheduler.UnobservedTaskException += (sender, e) => + { + try + { + Trace.TraceError("[FwAvaloniaPreviewHost] Unobserved task exception." + Environment.NewLine + e.Exception); + Trace.Flush(); + e.SetObserved(); + } + catch + { + } + }; + + AppDomain.CurrentDomain.ProcessExit += (sender, e) => + { + try + { + Trace.WriteLine("[FwAvaloniaPreviewHost] ProcessExit."); + Trace.Flush(); + } + catch + { + } + }; + + PreviewHostLogging.Initialize(); + PreviewOptions.Current = PreviewOptions.Parse(args); + Trace.WriteLine("[FwAvaloniaPreviewHost] Starting. module='" + PreviewOptions.Current.ModuleId + "' data='" + PreviewOptions.Current.DataMode + "'"); + + BuildAvaloniaApp().StartWithClassicDesktopLifetime(Array.Empty()); + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace(); + } +} diff --git a/Src/Common/FwUtils/FwApplicationSettings.cs b/Src/Common/FwUtils/FwApplicationSettings.cs index 42081a1a86..df81dc7a18 100644 --- a/Src/Common/FwUtils/FwApplicationSettings.cs +++ b/Src/Common/FwUtils/FwApplicationSettings.cs @@ -37,6 +37,12 @@ public override UpdateSettings Update set => m_settings.Update = value; } + public override string UIMode + { + get => m_settings.UIMode; + set => m_settings.UIMode = value; + } + public override string LocalKeyboards { get { return m_settings.LocalKeyboards; } diff --git a/Src/Common/FwUtils/FwApplicationSettingsBase.cs b/Src/Common/FwUtils/FwApplicationSettingsBase.cs index a2ded9ef9e..a905e1ed80 100644 --- a/Src/Common/FwUtils/FwApplicationSettingsBase.cs +++ b/Src/Common/FwUtils/FwApplicationSettingsBase.cs @@ -17,6 +17,7 @@ public abstract class FwApplicationSettingsBase { public abstract ReportingSettings Reporting { get; set; } public abstract UpdateSettings Update { get; set; } + public abstract string UIMode { get; set; } public abstract string LocalKeyboards { get; set; } public abstract string WebonaryUser { get; set; } public abstract string WebonaryPass { get; set; } diff --git a/Src/Common/FwUtils/FwUtilsTests/TestFwApplicationSettings.cs b/Src/Common/FwUtils/FwUtilsTests/TestFwApplicationSettings.cs index ce9895ae9c..d4034a23a6 100644 --- a/Src/Common/FwUtils/FwUtilsTests/TestFwApplicationSettings.cs +++ b/Src/Common/FwUtils/FwUtilsTests/TestFwApplicationSettings.cs @@ -19,6 +19,8 @@ public class TestFwApplicationSettings : FwApplicationSettingsBase public override UpdateSettings Update { get; set; } + public override string UIMode { get; set; } + public override string LocalKeyboards { get; set; } /// diff --git a/Src/Common/FwUtils/Properties/Settings.Designer.cs b/Src/Common/FwUtils/Properties/Settings.Designer.cs index 8425684a9a..51840f7ecc 100644 --- a/Src/Common/FwUtils/Properties/Settings.Designer.cs +++ b/Src/Common/FwUtils/Properties/Settings.Designer.cs @@ -71,6 +71,23 @@ public bool UpdateGlobalWSStore { this["Update"] = value; } } + + /// + /// Preferred lexical-edit UI mode (Legacy or New) + /// + [global::System.Configuration.UserScopedSettingAttribute()] + [global::System.Configuration.SettingsProviderAttribute(typeof(SIL.Settings.CrossPlatformSettingsProvider))] + [global::System.Configuration.SettingsDescriptionAttribute("Preferred lexical-edit UI mode (Legacy or New)")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Configuration.DefaultSettingValueAttribute("Legacy")] + public string UIMode { + get { + return ((string)(this["UIMode"])); + } + set { + this["UIMode"] = value; + } + } /// /// Setting indicating that the Settings need to be upgraded diff --git a/Src/Common/FwUtils/Properties/Settings.settings b/Src/Common/FwUtils/Properties/Settings.settings index b0b27c60d5..a7e7b80265 100644 --- a/Src/Common/FwUtils/Properties/Settings.settings +++ b/Src/Common/FwUtils/Properties/Settings.settings @@ -11,6 +11,9 @@ + + Legacy + True diff --git a/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj b/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj index de4cb0056e..8c42870774 100644 --- a/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj +++ b/Src/Common/RenderTestInfrastructure/RenderTestInfrastructure.csproj @@ -55,6 +55,9 @@ RenderSnapshotVerifier.cs + + RenderFailureArtifactBundler.cs + RenderEnvironmentValidator.cs diff --git a/Src/Common/RenderVerification/RenderFailureArtifactBundler.cs b/Src/Common/RenderVerification/RenderFailureArtifactBundler.cs new file mode 100644 index 0000000000..675625ea3e --- /dev/null +++ b/Src/Common/RenderVerification/RenderFailureArtifactBundler.cs @@ -0,0 +1,188 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace SIL.FieldWorks.Common.RenderVerification +{ + /// + /// Bundles render/parity verification FAILURE artifacts into a single CI-discoverable folder so a + /// failed snapshot comparison is diagnosable from the build output (task 2.5 of + /// lexical-edit-avalonia-migration). Given a failed + /// it copies the received image, received metadata, and diff image into one folder and writes a + /// failure-summary.json describing the test and the pixel-diff metrics. + /// + /// It is defensive: missing source files (e.g. a missing-baseline failure has no diff) are skipped, + /// and writing a bundle never throws into the calling test. Use it on the failure path of a render + /// test before asserting, so the artifacts exist regardless of the assertion outcome. + /// + public static class RenderFailureArtifactBundler + { + /// + /// Writes a failure-artifact bundle for a failed verification and returns the bundle folder path. + /// Returns null when is null or passed (nothing to bundle). + /// + /// The failed verification result. + /// Owning test class (for the bundle name and summary). + /// Owning test method (for the bundle name and summary). + /// Scenario identifier (for the bundle name and summary). + /// + /// Optional root folder for failure bundles. When null, a _RenderFailures folder next to + /// the received/baseline artifact is used so the location is always writable. + /// + public static string BundleFailureArtifacts( + RenderBaselineVerificationResult result, + string testClassName, + string testMethodName, + string scenarioId, + string outputRoot = null) + { + if (result == null || result.Passed) + { + return null; + } + + try + { + var baseDir = outputRoot ?? Path.Combine(DeriveBaseDirectory(result), "_RenderFailures"); + var runId = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss", CultureInfo.InvariantCulture); + var bundleName = Sanitize($"{testClassName}_{testMethodName}_{scenarioId}"); + var bundleFolder = Path.Combine(baseDir, runId, bundleName); + Directory.CreateDirectory(bundleFolder); + + CopyIfPresent(result.ReceivedPath, Path.Combine(bundleFolder, "actual.png")); + CopyIfPresent(result.ReceivedMetadataPath, Path.Combine(bundleFolder, "actual-metadata.json")); + CopyIfPresent(result.DiffPath, Path.Combine(bundleFolder, "diff.png")); + CopyIfPresent(result.DiffMetadataPath, Path.Combine(bundleFolder, "diff-metadata.json")); + + if (!string.IsNullOrEmpty(result.VerifiedPath)) + { + File.WriteAllText( + Path.Combine(bundleFolder, "expected-image-path.txt"), + result.VerifiedPath); + } + + File.WriteAllText( + Path.Combine(bundleFolder, "failure-summary.json"), + BuildFailureSummaryJson(result, testClassName, testMethodName, scenarioId, bundleFolder)); + + return bundleFolder; + } + catch (Exception ex) + { + // Bundling is best-effort diagnostics; never mask the real test failure. + Console.Error.WriteLine($"RenderFailureArtifactBundler failed: {ex.Message}"); + return null; + } + } + + private static string DeriveBaseDirectory(RenderBaselineVerificationResult result) + { + foreach (var path in new[] { result.ReceivedPath, result.VerifiedPath, result.DiffPath }) + { + if (!string.IsNullOrEmpty(path)) + { + var dir = Path.GetDirectoryName(path); + if (!string.IsNullOrEmpty(dir)) + { + return dir; + } + } + } + + return Path.GetTempPath(); + } + + private static void CopyIfPresent(string source, string destination) + { + if (!string.IsNullOrEmpty(source) && File.Exists(source)) + { + File.Copy(source, destination, overwrite: true); + } + } + + private static string Sanitize(string value) + { + var sb = new StringBuilder(value.Length); + foreach (var c in value) + { + sb.Append(Array.IndexOf(Path.GetInvalidFileNameChars(), c) >= 0 ? '_' : c); + } + + return sb.ToString(); + } + + private static string BuildFailureSummaryJson( + RenderBaselineVerificationResult result, + string testClassName, + string testMethodName, + string scenarioId, + string bundleFolder) + { + var diff = result.DiffSummary; + var fields = new List + { + JsonField("testClassName", testClassName), + JsonField("testMethodName", testMethodName), + JsonField("scenarioId", scenarioId), + JsonField("capturedAtUtc", DateTime.UtcNow.ToString("o", CultureInfo.InvariantCulture)), + JsonField("failureKind", DetermineFailureKind(result)), + JsonField("failureMessage", result.FailureMessage ?? ""), + JsonField("bundleFolder", bundleFolder), + JsonField("expectedImagePath", result.VerifiedPath ?? ""), + JsonField("actualImagePath", result.ReceivedPath ?? ""), + JsonField("diffImagePath", result.DiffPath ?? "") + }; + + if (diff != null) + { + fields.Add(JsonRaw("differentPixelCount", diff.DifferentPixelCount.ToString(CultureInfo.InvariantCulture))); + fields.Add(JsonRaw("diffRegionWidth", diff.DiffRegionWidth.ToString(CultureInfo.InvariantCulture))); + fields.Add(JsonRaw("diffRegionHeight", diff.DiffRegionHeight.ToString(CultureInfo.InvariantCulture))); + } + + return "{" + string.Join(",", fields) + "}"; + } + + private static string DetermineFailureKind(RenderBaselineVerificationResult result) + { + if (result.DiffSummary != null && result.DiffSummary.DifferentPixelCount > 0) + { + return "pixel-mismatch"; + } + + if (string.IsNullOrEmpty(result.VerifiedPath) || !File.Exists(result.VerifiedPath)) + { + return "missing-baseline"; + } + + return "verification-failed"; + } + + private static string JsonField(string name, string value) + => $"\"{name}\":\"{Escape(value)}\""; + + private static string JsonRaw(string name, string rawValue) + => $"\"{name}\":{rawValue}"; + + private static string Escape(string value) + { + if (string.IsNullOrEmpty(value)) + { + return ""; + } + + return value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", "\\r") + .Replace("\n", "\\n") + .Replace("\t", "\\t"); + } + } +} diff --git a/Src/Common/ViewsInterfaces/ViewsInterfaces.csproj b/Src/Common/ViewsInterfaces/ViewsInterfaces.csproj index 913f0fa270..ae02a12d79 100644 --- a/Src/Common/ViewsInterfaces/ViewsInterfaces.csproj +++ b/Src/Common/ViewsInterfaces/ViewsInterfaces.csproj @@ -7,6 +7,7 @@ net48 Library 168,169,219,414,649,1635,1702,1701 false + false false diff --git a/Src/LexText/LexTextControls/LexOptionsDlg.cs b/Src/LexText/LexTextControls/LexOptionsDlg.cs index 993940e631..e06d8bfed0 100644 --- a/Src/LexText/LexTextControls/LexOptionsDlg.cs +++ b/Src/LexText/LexTextControls/LexOptionsDlg.cs @@ -36,10 +36,16 @@ public partial class LexOptionsDlg : Form, IFwExtension private readonly Dictionary m_plugins = new Dictionary(); private readonly Dictionary m_channels; private readonly Dictionary m_QaChannels; + private const string UIModePropertyName = "UIMode"; + private const string LegacyUIMode = "Legacy"; + private const string NewUIMode = "New"; private const string HelpTopic = "khtpLexOptions"; private IHelpTopicProvider m_helpTopicProvider; private FwApplicationSettingsBase m_settings; + private GroupBox m_uiModeGroup; + private Label m_uiModeLabel; + private ComboBox m_uiModeChooser; private FwApp App => m_propertyTable?.GetValue("App") ?? m_helpTopicProvider as FwApp; public LexOptionsDlg() @@ -63,6 +69,7 @@ public LexOptionsDlg() [UpdateSettings.Channels.Testing] = new UpdateChannelMenuItem(UpdateSettings.Channels.Testing, "Test Model Change", "This option is only for testing related to model changes - This will not install a real FieldWorks update") }; + InitializeUIModeControls(); } /// @@ -75,6 +82,7 @@ protected override void OnLoad(EventArgs e) base.OnLoad(e); m_autoOpenCheckBox.Checked = AutoOpenLastProject; m_okToPingCheckBox.Checked = m_settings.Reporting.OkToPingBasicUsageData; + SelectUIMode(NormalizeUIMode(m_settings.UIMode)); if (Platform.IsWindows) { if (m_settings.Update == null) @@ -129,6 +137,18 @@ private void m_btnOK_Click(object sender, EventArgs e) } } + var oldUiMode = NormalizeUIMode(m_settings.UIMode); + var newUiMode = SelectedUIMode; + if (oldUiMode != newUiMode) + { + m_settings.UIMode = newUiMode; + if (m_propertyTable != null) + { + m_propertyTable.SetProperty(UIModePropertyName, newUiMode, true); + m_propertyTable.SetPropertyPersistence(UIModePropertyName, false); + } + } + m_sNewUserWs = m_userInterfaceChooser.NewUserWs; if (m_sUserWs != m_sNewUserWs) { @@ -344,6 +364,8 @@ public void InitBareBones(IHelpTopicProvider helpTopicProvider) { m_helpTopicProvider = helpTopicProvider; m_settings = new FwApplicationSettings(); + if (string.IsNullOrWhiteSpace(m_settings.UIMode)) + m_settings.UIMode = LegacyUIMode; m_sUserWs = FwRegistryHelper.FieldWorksRegistryKey.GetValue(FwRegistryHelper.UserLocaleValueName, "en") as string; m_sNewUserWs = m_sUserWs; m_userInterfaceChooser.Init(m_sUserWs); @@ -410,5 +432,102 @@ public override string ToString() return m_name; } } + + private void InitializeUIModeControls() + { + m_uiModeGroup = new GroupBox(); + m_uiModeLabel = new Label(); + m_uiModeChooser = new ComboBox(); + + m_uiModeGroup.Text = GetOptionString("UiModeGroupTitle", "Lexical Edit UI:"); + m_uiModeGroup.Left = groupBox1.Left; + m_uiModeGroup.Top = groupBox1.Bottom + 6; + m_uiModeGroup.Width = groupBox1.Width; + m_uiModeGroup.Height = 68; + m_uiModeGroup.Name = "m_uiModeGroup"; + + m_uiModeLabel.AutoSize = true; + m_uiModeLabel.Left = 6; + m_uiModeLabel.Top = 22; + m_uiModeLabel.Text = GetOptionString("UiModeLabel", "Mode:"); + m_uiModeLabel.Name = "m_uiModeLabel"; + + m_uiModeChooser.DropDownStyle = ComboBoxStyle.DropDownList; + m_uiModeChooser.Left = 6; + m_uiModeChooser.Top = 38; + m_uiModeChooser.Width = m_userInterfaceChooser.Width; + m_uiModeChooser.Name = "m_uiModeChooser"; + m_uiModeChooser.Items.Add(new UiModeMenuItem(LegacyUIMode, GetOptionString("UiModeLegacy", "Legacy"))); + m_uiModeChooser.Items.Add(new UiModeMenuItem(NewUIMode, GetOptionString("UiModeNew", "New"))); + + m_uiModeGroup.Controls.Add(m_uiModeLabel); + m_uiModeGroup.Controls.Add(m_uiModeChooser); + m_tabInterface.Controls.Add(m_uiModeGroup); + + var delta = m_uiModeGroup.Bottom + 8 - label4.Top; + if (delta > 0) + { + label4.Top += delta; + m_autoOpenCheckBox.Top += delta; + tabControl1.Height += delta; + m_btnOK.Top += delta; + m_btnCancel.Top += delta; + m_btnHelp.Top += delta; + Height += delta; + } + } + + private string SelectedUIMode + { + get + { + var item = m_uiModeChooser.SelectedItem as UiModeMenuItem; + return item != null ? item.Mode : LegacyUIMode; + } + } + + private void SelectUIMode(string mode) + { + var desired = NormalizeUIMode(mode); + foreach (var item in m_uiModeChooser.Items) + { + var uiMode = item as UiModeMenuItem; + if (uiMode != null && uiMode.Mode == desired) + { + m_uiModeChooser.SelectedItem = uiMode; + return; + } + } + } + + private static string NormalizeUIMode(string mode) + { + return string.Equals(mode, NewUIMode, StringComparison.OrdinalIgnoreCase) + ? NewUIMode + : LegacyUIMode; + } + + private static string GetOptionString(string resourceName, string fallback) + { + var value = LexTextControls.ResourceManager.GetString(resourceName, LexTextControls.Culture); + return string.IsNullOrEmpty(value) ? fallback : value; + } + + private sealed class UiModeMenuItem + { + public UiModeMenuItem(string mode, string display) + { + Mode = mode; + Display = display; + } + + public string Mode { get; } + private string Display { get; } + + public override string ToString() + { + return Display; + } + } } } diff --git a/Src/LexText/LexTextControls/LexTextControls.resx b/Src/LexText/LexTextControls/LexTextControls.resx index 8f771e65a0..9c6306dd43 100644 --- a/Src/LexText/LexTextControls/LexTextControls.resx +++ b/Src/LexText/LexTextControls/LexTextControls.resx @@ -1313,6 +1313,22 @@ Sorry, in this browser, opening the file via hyperlink presents a security risk. You can add other language options by running the installer, using Modify, and choosing to install Language Packs. See Help for details. + + Lexical Edit UI: + Group-box title on Tools->Options->Interface for choosing the lexical edit UI mode. + + + Mode: + Label for the lexical edit UI mode chooser on Tools->Options->Interface. + + + Legacy + Uses the existing WinForms DataTree lexical edit UI. + + + New + Uses the new Avalonia lexical edit UI. + Choose phonological features diff --git a/Src/xWorks/LexicalEditPocMapper.cs b/Src/xWorks/LexicalEditPocMapper.cs new file mode 100644 index 0000000000..849e7060ae --- /dev/null +++ b/Src/xWorks/LexicalEditPocMapper.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Poc; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Maps the current Lexical Edit record (`LexEntry`) into the detached DTO consumed by the + /// Avalonia POC surface. This is intentionally small and lossy: it only projects the three fields + /// the current POC renders (lexeme form, morph type, first-sense gloss). The legacy WinForms + /// DataTree remains the full-fidelity path; this mapper exists only for the feature-flagged + /// in-app spike. + /// + public static class LexicalEditPocMapper + { + public static PocEntryDto CreateDto(ICmObject obj, LcmCache cache) + { + var entry = obj as ILexEntry; + if (entry == null) + { + return null; + } + + return new PocEntryDto( + BuildLexemeForm(entry), + BuildMorphTypeOptions(), + GetMorphTypeKey(entry), + BuildFirstSenseGloss(entry, cache)); + } + + private static IList BuildLexemeForm(ILexEntry entry) + { + var values = new List(); + var lexemeText = entry.LexemeFormOA != null && entry.LexemeFormOA.Form != null + ? entry.LexemeFormOA.Form.VernacularDefaultWritingSystem.Text + : string.Empty; + if (string.IsNullOrEmpty(lexemeText)) + { + lexemeText = entry.CitationForm.VernacularDefaultWritingSystem.Text; + } + + values.Add(new WsAlternative("vern", lexemeText)); + return values; + } + + private static IList BuildFirstSenseGloss(ILexEntry entry, LcmCache cache) + { + var values = new List(); + if (entry.SensesOS.Count > 0) + { + var gloss = entry.SensesOS[0].Gloss.AnalysisDefaultWritingSystem.Text; + values.Add(new WsAlternative("anal", gloss, "Times New Roman")); + } + else + { + values.Add(new WsAlternative("anal", string.Empty, "Times New Roman")); + } + + return values; + } + + private static IList BuildMorphTypeOptions() + { + return new List + { + new MorphTypeOption("stem", "stem"), + new MorphTypeOption("root", "root"), + new MorphTypeOption("prefix", "prefix"), + new MorphTypeOption("suffix", "suffix") + }; + } + + private static string GetMorphTypeKey(ILexEntry entry) + { + var type = entry.LexemeFormOA != null ? entry.LexemeFormOA.MorphTypeRA : null; + if (type == null) + { + return "stem"; + } + + if (type.Guid == MoMorphTypeTags.kguidMorphPrefix) + { + return "prefix"; + } + if (type.Guid == MoMorphTypeTags.kguidMorphSuffix) + { + return "suffix"; + } + if (type.Guid == MoMorphTypeTags.kguidMorphRoot || type.Guid == MoMorphTypeTags.kguidMorphBoundRoot) + { + return "root"; + } + + return "stem"; + } + } +} diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index 413ac65c64..527388bc98 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -8,6 +8,8 @@ using System.Drawing.Printing; using System.Windows.Forms; using System.Xml; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Poc; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.LCModel; using XCore; @@ -60,6 +62,10 @@ public class RecordEditView : RecordView, IVwNotifyChange, IFocusablePanePortion private string m_titleField; private string m_titleStr; private string m_printLayout; + private LexicalEditSurface m_lexicalEditSurface; + private readonly LexicalEditSurfaceFactory m_lexicalEditSurfaceFactory; + private PocWinFormsHostControl m_avaloniaEntryForm; + private bool m_legacySurfaceInitialized; //// //// used to associate menu commands with the slice that sent them @@ -80,7 +86,11 @@ public RecordEditView() protected RecordEditView(DataTree dataEntryForm) { // This must be called before InitializeComponent() + m_lexicalEditSurface = LexicalEditSurface.WinForms; m_dataEntryForm = dataEntryForm; + m_lexicalEditSurfaceFactory = new LexicalEditSurfaceFactory( + () => m_dataEntryForm, + () => new PocWinFormsHostControl()); m_dataEntryForm.CurrentSliceChanged += m_dataEntryForm_CurrentSliceChanged; // This call is required by the Windows.Forms Form Designer. @@ -123,7 +133,9 @@ public override void Init(Mediator mediator, PropertyTable propertyTable, XmlNod } // If possible make it use the style sheet appropriate for its main window. - m_dataEntryForm.StyleSheet = FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable); + m_lexicalEditSurface = ResolveConfiguredLexicalEditSurface(); + if (!ShouldUseAvaloniaLexicalEdit) + m_dataEntryForm.StyleSheet = FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable); m_fullyInitialized = true; Subscriber.Subscribe(EventConstants.ConsideringClosing, ConsideringClosing); @@ -155,11 +167,13 @@ protected override void Dispose(bool disposing) m_dataEntryForm.CurrentSliceChanged -= m_dataEntryForm_CurrentSliceChanged; m_dataEntryForm.Dispose(); } + m_avaloniaEntryForm?.Dispose(); m_menuHandler?.Dispose(); if (!string.IsNullOrEmpty(m_titleField)) Cache.DomainDataByFlid.RemoveNotification(this); } m_dataEntryForm = null; + m_avaloniaEntryForm = null; base.Dispose(disposing); } @@ -220,7 +234,7 @@ public override bool PrepareToGoAway() { CheckDisposed(); - if (m_dataEntryForm != null) + if (!ShouldUseAvaloniaLexicalEdit && m_dataEntryForm != null) m_dataEntryForm.PrepareToGoAway(); return base.PrepareToGoAway(); } @@ -237,6 +251,21 @@ private void m_dataEntryForm_CurrentSliceChanged(object sender, EventArgs e) Clerk.JumpToRecord(m_dataEntryForm.Descendant.Hvo, true); } + public void OnPropertyChanged(string name) + { + CheckDisposed(); + + if (name != LexicalEditSurfaceResolver.UIModePropertyName) + return; + + var newSurface = ResolveConfiguredLexicalEditSurface(); + if (newSurface == m_lexicalEditSurface) + return; + + m_lexicalEditSurface = newSurface; + ShowRecord(new RecordNavigationInfo(Clerk, Clerk.SuppressSaveOnChangeRecord, false, true)); + } + #endregion // Message Handlers #region Other methods @@ -314,13 +343,37 @@ bool ShowRecordOnIdle(object parameter) if (Clerk.CurrentObject == null || Clerk.SuspendLoadingRecordUntilOnJumpToRecord) { - m_dataEntryForm.Hide(); - m_dataEntryForm.Reset(); // in case user deleted the object it was based upon. + if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) + { + m_dataEntryForm.Hide(); + m_avaloniaEntryForm.Hide(); + m_avaloniaEntryForm.Clear(); + } + else + { + m_dataEntryForm.Hide(); + m_dataEntryForm.Reset(); // in case user deleted the object it was based upon. + } return true; } try { - m_dataEntryForm.Show(); + if (!m_legacySurfaceInitialized) + { + var localPersistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); + if (localPersistContext != "") + localPersistContext = m_vectorName + "." + localPersistContext + ".DataTree"; + else + localPersistContext = m_vectorName + ".DataTree"; + EnsureLegacySurfaceInitialized(localPersistContext); + } + if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm == null) + { + EnsureAvaloniaSurfaceInitialized(); + } + + if (!ShouldUseAvaloniaLexicalEdit) + m_dataEntryForm.Show(); // Enhance: Maybe do something here to allow changing the templates without the starting the application. ICmObject obj = Clerk.CurrentObject; @@ -331,7 +384,21 @@ bool ShowRecordOnIdle(object parameter) obj = obj.Owner; } - m_dataEntryForm.ShowObject(obj, m_layoutName, m_layoutChoiceField, Clerk.CurrentObject, ShouldSuppressFocusChange(rni)); + if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) + { + m_dataEntryForm.ShowObject(obj, m_layoutName, m_layoutChoiceField, Clerk.CurrentObject, true); + m_dataEntryForm.Hide(); + m_avaloniaEntryForm.Show(); + var dto = LexicalEditPocMapper.CreateDto(obj, Cache); + if (dto == null) + m_avaloniaEntryForm.ShowMessage("Avalonia lexical-edit POC is currently available only for LexEntry records."); + else + m_avaloniaEntryForm.ShowEntry(dto); + } + else + { + m_dataEntryForm.ShowObject(obj, m_layoutName, m_layoutChoiceField, Clerk.CurrentObject, ShouldSuppressFocusChange(rni)); + } } catch (Exception error) { @@ -356,6 +423,54 @@ private bool ShouldSuppressFocusChange(RecordNavigationInfo rni) return !IsFocusedPane || rni.SuppressFocusChange; } + private LexicalEditSurface ResolveConfiguredLexicalEditSurface() + { + var uiMode = m_propertyTable != null + ? m_propertyTable.GetStringProperty(LexicalEditSurfaceResolver.UIModePropertyName, LexicalEditSurfaceResolver.LegacyUIMode) + : LexicalEditSurfaceResolver.LegacyUIMode; + + return LexicalEditSurfaceResolver.Resolve(uiMode: uiMode); + } + + private void EnsureLegacySurfaceInitialized(string persistContext) + { + if (m_legacySurfaceInitialized) + return; + + m_dataEntryForm.PersistenceProvder = new PersistenceProvider(m_mediator, m_propertyTable, persistContext); + + Clerk.UpdateRecordTreeBarIfNeeded(); + SetupSliceFilter(); + m_dataEntryForm.Dock = DockStyle.Fill; + m_dataEntryForm.SmallImages = m_propertyTable.GetValue("smallImages"); + string sDatabase = Cache.ProjectId.Name; + m_dataEntryForm.Initialize(Cache, true, Inventory.GetInventory("layouts", sDatabase), + Inventory.GetInventory("parts", sDatabase)); + m_dataEntryForm.Init(m_mediator, m_propertyTable, m_configurationParameters); + if (m_dataEntryForm.AccessibilityObject != null) + m_dataEntryForm.AccessibilityObject.Name = "RecordEditView.DataTree"; + + m_menuHandler = DTMenuHandler.Create(m_dataEntryForm, m_configurationParameters); + m_menuHandler.Init(m_mediator, m_propertyTable, m_configurationParameters); + m_dataEntryForm.SetContextMenuHandler(m_menuHandler.ShowSliceContextMenu); + + if (!m_panel.Controls.Contains(m_dataEntryForm)) + m_panel.Controls.Add(m_dataEntryForm); + + m_legacySurfaceInitialized = true; + } + + private void EnsureAvaloniaSurfaceInitialized() + { + if (m_avaloniaEntryForm != null) + return; + + m_avaloniaEntryForm = (PocWinFormsHostControl)m_lexicalEditSurfaceFactory.Create(LexicalEditSurface.Avalonia); + m_avaloniaEntryForm.Dock = DockStyle.Fill; + if (!m_panel.Controls.Contains(m_avaloniaEntryForm)) + m_panel.Controls.Add(m_avaloniaEntryForm); + } + /// ------------------------------------------------------------------------------------ /// /// Base method saves any time you switch between records. @@ -400,28 +515,20 @@ protected override void SetupDataContext() else persistContext=m_vectorName+".DataTree"; - m_dataEntryForm.PersistenceProvder = new PersistenceProvider(m_mediator, m_propertyTable, persistContext); - - Clerk.UpdateRecordTreeBarIfNeeded(); - SetupSliceFilter(); - m_dataEntryForm.Dock = DockStyle.Fill; - m_dataEntryForm.SmallImages = m_propertyTable.GetValue("smallImages"); - string sDatabase = Cache.ProjectId.Name; - m_dataEntryForm.Initialize(Cache, true, Inventory.GetInventory("layouts", sDatabase), - Inventory.GetInventory("parts", sDatabase)); - m_dataEntryForm.Init(m_mediator, m_propertyTable, m_configurationParameters); - if (m_dataEntryForm.AccessibilityObject != null) - m_dataEntryForm.AccessibilityObject.Name = "RecordEditView.DataTree"; - //set up the context menu, overriding the automatic menu creator/handler - - m_menuHandler = DTMenuHandler.Create(m_dataEntryForm, m_configurationParameters); - m_menuHandler.Init(m_mediator, m_propertyTable, m_configurationParameters); - -// m_dataEntryForm.SetContextMenuHandler(new SliceMenuRequestHandler((m_menuHandler.GetSliceContextMenu)); - m_dataEntryForm.SetContextMenuHandler(m_menuHandler.ShowSliceContextMenu); - - Controls.Add(m_dataEntryForm); - m_dataEntryForm.BringToFront(); + EnsureLegacySurfaceInitialized(persistContext); + if (ShouldUseAvaloniaLexicalEdit) + { + EnsureAvaloniaSurfaceInitialized(); + m_dataEntryForm.Hide(); + m_avaloniaEntryForm.Show(); + m_avaloniaEntryForm.BringToFront(); + } + else + { + m_avaloniaEntryForm?.Hide(); + m_dataEntryForm.Show(); + m_dataEntryForm.BringToFront(); + } } /// @@ -486,10 +593,11 @@ protected override void GetMessageAdditionalTargets(List collec if(!m_fullyInitialized) return; - if (m_dataEntryForm != null) // Unlikely it is null, but I have observed it..JohnT. + if (!ShouldUseAvaloniaLexicalEdit && m_dataEntryForm != null) // Unlikely it is null, but I have observed it..JohnT. collector.Add(m_dataEntryForm); - collector.Add(m_menuHandler); + if (!ShouldUseAvaloniaLexicalEdit && m_menuHandler != null) + collector.Add(m_menuHandler); } #region IxCoreCtrlTabProvider implementation @@ -499,6 +607,12 @@ public override Control PopulateCtrlTabTargetCandidateList(List targetC if (targetCandidates == null) throw new ArgumentNullException("targetCandidates"); + if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) + { + targetCandidates.Add(m_avaloniaEntryForm); + return m_avaloniaEntryForm.ContainsFocus ? m_avaloniaEntryForm : null; + } + // when switching panes, we want to give the focus to the CurrentSlice(if any) if (m_dataEntryForm != null && m_dataEntryForm.CurrentSlice != null) { @@ -511,6 +625,11 @@ public override Control PopulateCtrlTabTargetCandidateList(List targetC #endregion IxCoreCtrlTabProvider implementation + private bool ShouldUseAvaloniaLexicalEdit + { + get { return m_lexicalEditSurface == LexicalEditSurface.Avalonia; } + } + #region Component Designer generated code /// ----------------------------------------------------------------------------------- /// diff --git a/Src/xWorks/xWorks.csproj b/Src/xWorks/xWorks.csproj index 692d3416ec..f6ab67b2ca 100644 --- a/Src/xWorks/xWorks.csproj +++ b/Src/xWorks/xWorks.csproj @@ -58,6 +58,7 @@ + diff --git a/Src/xWorks/xWorksTests/BulkEditBarTests.cs b/Src/xWorks/xWorksTests/BulkEditBarTests.cs index de1ae76dbd..0504a2d540 100644 --- a/Src/xWorks/xWorksTests/BulkEditBarTests.cs +++ b/Src/xWorks/xWorksTests/BulkEditBarTests.cs @@ -372,7 +372,16 @@ internal FilterSortItem SetFilter(string columnName, string filterType, string q { // get ColumnInfo for specified column FilterSortItem fsiTarget = FindColumnInfo(columnName); - int index = fsiTarget.Combo.FindStringExact(filterType); + int index; + // Avoid matching localized UI strings for the common filter operations. + if (filterType == "Show All") + index = (fsiTarget.Combo.Items.Count > 0 && fsiTarget.Combo.Items[0] is FilterComboItem) ? 0 : -1; + else if (filterType == "Filter for...") + index = FindIndexByItemType(fsiTarget.Combo); + else if (filterType == "Choose...") + index = FindIndexByItemType(fsiTarget.Combo); + else + index = fsiTarget.Combo.FindStringExact(filterType); if (index < 0) return null; @@ -401,14 +410,65 @@ internal FilterSortItem SetFilter(string columnName, string filterType, string q return fsiTarget; } + private static int FindIndexByItemType(SIL.FieldWorks.Common.Widgets.IComboList combo) + where TItem : class + { + for (int i = 0; i < combo.Items.Count; i++) + { + if (combo.Items[i] is TItem) + return i; + } + return -1; + } + + private static bool ContainsItemType(SIL.FieldWorks.Common.Widgets.IComboList combo) + where TItem : class + { + return FindIndexByItemType(combo) >= 0; + } + + internal IReadOnlyList GetFilterReachabilityBaseline() + { + return m_filterBar.ColumnInfo.Select((fsi, index) => new FilterReachabilityRow( + index, + GetColumnLabel(fsi.Spec), + GetOptionalAttributeValue(fsi.Spec, "layout"), + GetOptionalAttributeValue(fsi.Spec, "field"), + GetOptionalAttributeValue(fsi.Spec, "subfield"), + GetOptionalAttributeValue(fsi.Spec, "list"), + fsi.Combo.Name, + fsi.Combo.Enabled, + fsi.Combo.IsDisposed, + fsi.Combo.Items.Count > 0 && fsi.Combo.Items[0] is FilterComboItem, + ContainsItemType(fsi.Combo), + ContainsItemType(fsi.Combo))).ToList(); + } + + private static string GetOptionalAttributeValue(XmlNode spec, string attributeName) + { + return spec != null && spec.Attributes != null && spec.Attributes[attributeName] != null + ? spec.Attributes[attributeName].Value + : string.Empty; + } + + private static string GetColumnLabel(XmlNode spec) + { + var header = spec.Attributes["headerlabel"] != null ? spec.Attributes["headerlabel"].Value : null; + if (!string.IsNullOrEmpty(header)) + return header; + + return spec.Attributes["label"] != null ? spec.Attributes["label"].Value : string.Empty; + } + private FilterSortItem FindColumnInfo(string columnName) { FilterSortItem fsiTarget = null; foreach (FilterSortItem fsi in m_filterBar.ColumnInfo) { - if (fsi.Spec.Attributes["label"].Value == columnName || - fsi.Spec.Attributes["headerlabel"] != null && - fsi.Spec.Attributes["headerlabel"].Value == columnName) + var label = fsi.Spec.Attributes["label"] != null ? fsi.Spec.Attributes["label"].Value : string.Empty; + var headerLabel = fsi.Spec.Attributes["headerlabel"] != null ? fsi.Spec.Attributes["headerlabel"].Value : string.Empty; + var layout = fsi.Spec.Attributes["layout"] != null ? fsi.Spec.Attributes["layout"].Value : string.Empty; + if (label == columnName || headerLabel == columnName || layout == columnName) { fsiTarget = fsi; break; @@ -472,6 +532,50 @@ internal IList UncheckedItems() return uncheckedItems; } + + internal sealed class FilterReachabilityRow + { + internal FilterReachabilityRow( + int focusOrder, + string headerLabel, + string columnLayout, + string field, + string subfield, + string listId, + string comboName, + bool comboEnabled, + bool comboDisposed, + bool hasShowAll, + bool hasFilterFor, + bool hasChoose) + { + FocusOrder = focusOrder; + HeaderLabel = headerLabel; + ColumnLayout = columnLayout; + Field = field; + Subfield = subfield; + ListId = listId; + ComboName = comboName; + ComboEnabled = comboEnabled; + ComboDisposed = comboDisposed; + HasShowAll = hasShowAll; + HasFilterFor = hasFilterFor; + HasChoose = hasChoose; + } + + internal int FocusOrder { get; } + internal string HeaderLabel { get; } + internal string ColumnLayout { get; } + internal string Field { get; } + internal string Subfield { get; } + internal string ListId { get; } + internal string ComboName { get; } + internal bool ComboEnabled { get; } + internal bool ComboDisposed { get; } + internal bool HasShowAll { get; } + internal bool HasFilterFor { get; } + internal bool HasChoose { get; } + } } protected class RecordBrowseViewForTests : RecordBrowseView @@ -500,6 +604,25 @@ protected override void PersistSortSequence() public class BulkEditBarTests : BulkEditBarTestsBase { #region BulkEditEntries tests + [Test] + public void FilterBar_HeaderAndFilterControlsExposeReachableBaseline() + { + var baseline = m_bv.GetFilterReachabilityBaseline(); + var lexemeForm = baseline.Single(row => row.ColumnLayout == "LexemeFormForEntry"); + var morphType = baseline.Single(row => row.ColumnLayout == "MorphTypeForEntry"); + + Assert.That(lexemeForm.FocusOrder, Is.LessThan(morphType.FocusOrder)); + Assert.That(lexemeForm.ComboEnabled, Is.True); + Assert.That(lexemeForm.ComboDisposed, Is.False); + Assert.That(lexemeForm.HasShowAll, Is.True); + Assert.That(lexemeForm.HasFilterFor, Is.True); + + Assert.That(morphType.ComboEnabled, Is.True); + Assert.That(morphType.ComboDisposed, Is.False); + Assert.That(morphType.HasShowAll, Is.True); + Assert.That(morphType.HasChoose, Is.True); + } + [Test] public void ChoiceFilters() { @@ -507,11 +630,11 @@ public void ChoiceFilters() m_bulkEditBar.SwitchTab("ListChoice"); // first apply a filter on Lexeme Form for 'underlying form' to limit browse view to one Entry. //FilterSortItem fsFilter = m_bv.SetFilter("Lexeme Form", "Filter for...", "underlying form"); - m_bv.SetFilter("Lexeme Form", "Filter for...", "underlying form"); + m_bv.SetFilter("LexemeFormForEntry", "Filter for...", "underlying form"); // next make a chooser filter on "Entry Type" column //fsFilter = m_bv.SetFilter("Morph Type", "Choose...", "root"); - m_bv.SetFilter("Morph Type", "Choose...", "root"); - m_bv.SetSort("Lexeme Form"); + m_bv.SetFilter("MorphTypeForEntry", "Choose...", "root"); + m_bv.SetSort("LexemeFormForEntry"); // Make sure our filters have worked to limit the data Assert.That(m_bv.AllItems.Count, Is.EqualTo(1)); diff --git a/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs b/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs new file mode 100644 index 0000000000..1152736bca --- /dev/null +++ b/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs @@ -0,0 +1,50 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.Text; + +namespace SIL.FieldWorks.XWorks.xWorksTests +{ + [TestFixture] + public class LexicalEditPocMapperTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + [Test] + public void CreateDto_LexEntry_MapsLexemeFormMorphTypeAndFirstSenseGloss() + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + var lexemeForm = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = lexemeForm; + lexemeForm.Form.set_String( + Cache.DefaultVernWs, + TsStringUtils.MakeString("kazi", Cache.DefaultVernWs)); + entry.CitationForm.VernacularDefaultWritingSystem = + TsStringUtils.MakeString("citation", Cache.DefaultVernWs); + lexemeForm.MorphTypeRA = + Cache.ServiceLocator.GetInstance().GetObject(MoMorphTypeTags.kguidMorphPrefix); + + var sense = Cache.ServiceLocator.GetInstance().Create(); + entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, "to work"); + + var dto = LexicalEditPocMapper.CreateDto(entry, Cache); + + Assert.That(dto, Is.Not.Null); + Assert.That(dto.LexemeForm.Count, Is.EqualTo(1)); + Assert.That(dto.LexemeForm[0].Value, Is.EqualTo("kazi")); + Assert.That(dto.MorphTypeKey, Is.EqualTo("prefix")); + Assert.That(dto.SenseGloss.Count, Is.EqualTo(1)); + Assert.That(dto.SenseGloss[0].Value, Is.EqualTo("to work")); + } + + [Test] + public void CreateDto_NonLexEntry_ReturnsNull() + { + var sense = Cache.ServiceLocator.GetInstance().Create(); + Assert.That(LexicalEditPocMapper.CreateDto(sense, Cache), Is.Null); + } + } +} diff --git a/build.ps1 b/build.ps1 index 2a06b8e639..122b24a635 100644 --- a/build.ps1 +++ b/build.ps1 @@ -32,6 +32,12 @@ If set, includes optional utility applications (e.g. MigrateSqlDbs, LCMBrowser, UnicodeCharEditor) in the build. Default is false unless -BuildInstaller is specified, which enables it automatically. +.PARAMETER BuildAvalonia + If set, builds optional Avalonia projects that are present on the current branch after the main + FieldWorks build completes. This is preview-only by default and does not change the traversal build. + On this branch it builds the isolated `Src/Common/FwAvalonia/` projects; if a preview host and/or + feature module projects (for example `*.Avalonia`) are present, they are built too. + .PARAMETER Verbosity Specifies the amount of information to display in the build log. Values: q[uiet], m[inimal], n[ormal], d[etailed], diag[nostic]. @@ -151,6 +157,10 @@ .\build.ps1 -UseLocalLcm Builds FieldWorks, then builds liblcm from ../liblcm and copies DLLs into Output. +.EXAMPLE + .\build.ps1 -BuildAvalonia + Builds FieldWorks, then builds any Avalonia preview/module projects present on the current branch. + .NOTES FieldWorks is x64-only. The x86 platform is no longer supported. #> @@ -164,6 +174,7 @@ param( [switch]$RunTests, [string]$TestFilter, [switch]$BuildAdditionalApps, + [switch]$BuildAvalonia, [string]$Project = "FieldWorks.proj", [string]$Verbosity = "minimal", [ValidateSet('true', 'false', 'auto')] @@ -336,6 +347,86 @@ function Get-RepoStamp { } } +function Test-IsNet48CompatibleProject { + param([string]$ProjectPath) + + try { + [xml]$projectXml = Get-Content -LiteralPath $ProjectPath -Raw + $frameworks = @() + foreach ($propertyGroup in $projectXml.Project.PropertyGroup) { + if ($propertyGroup.TargetFramework) { + $frameworks += [string]$propertyGroup.TargetFramework + } + if ($propertyGroup.TargetFrameworks) { + $frameworks += ([string]$propertyGroup.TargetFrameworks -split ';') + } + } + + $frameworks = $frameworks | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { $_.Trim() } + + if ($frameworks.Count -eq 0) { + Write-Host "Skipping Avalonia project with no TargetFramework metadata: $ProjectPath" -ForegroundColor Yellow + return $false + } + + return $frameworks -contains 'net48' + } + catch { + Write-Host "Skipping Avalonia project whose target frameworks could not be read: $ProjectPath" -ForegroundColor Yellow + return $false + } +} + +function Get-AvaloniaProjectList { + param( + [string]$RepoRoot, + [bool]$IncludeTests + ) + + $projects = New-Object System.Collections.Generic.List[string] + + function Add-ProjectIfPresent { + param([string]$Path) + if (-not [string]::IsNullOrWhiteSpace($Path) -and (Test-Path $Path) -and -not $projects.Contains($Path)) { + if (Test-IsNet48CompatibleProject -ProjectPath $Path) { + $projects.Add($Path) + } + else { + Write-Host "Skipping non-net48 Avalonia project: $Path" -ForegroundColor Yellow + } + } + } + + # Shared Avalonia library on this branch. + Add-ProjectIfPresent (Join-Path $RepoRoot "Src/Common/FwAvalonia/FwAvalonia.csproj") + + # Optional module projects (e.g. Src/LexText/AdvancedEntry.Avalonia/*.csproj). + $srcRoot = Join-Path $RepoRoot "Src" + if (Test-Path $srcRoot) { + $moduleProjects = Get-ChildItem -Path $srcRoot -Recurse -Filter *.csproj -ErrorAction SilentlyContinue | + Where-Object { + $dirName = $_.Directory.Name + $dirName -match '\.Avalonia$' + } + foreach ($project in $moduleProjects) { + Add-ProjectIfPresent $project.FullName + } + } + + # Optional preview host. Net8-only hosts are intentionally skipped while this branch targets net48. + Add-ProjectIfPresent (Join-Path $RepoRoot "Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj") + + # Avalonia test project only when tests are already being built/run. + if ($IncludeTests) { + Add-ProjectIfPresent (Join-Path $RepoRoot "Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj") + Add-ProjectIfPresent (Join-Path $RepoRoot "Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/FwAvaloniaPreviewHostTests.csproj") + } + + return $projects.ToArray() +} + function Get-BuildStampPath { param( [Parameter(Mandatory = $true)][string]$RepoRoot, @@ -761,6 +852,36 @@ try { Write-Host "" Write-Host "[OK] Build complete!" -ForegroundColor Green Write-Host "Output: Output\$Configuration" -ForegroundColor Cyan + + if ($BuildAvalonia) { + Write-Host "" + Write-Host "Building net48-compatible Avalonia projects present on this branch..." -ForegroundColor Cyan + + $avaloniaProjects = Get-AvaloniaProjectList -RepoRoot $PSScriptRoot -IncludeTests ($BuildTests -or $RunTests) + if ($avaloniaProjects.Count -gt 0 -and (Test-Path $projectPath)) { + $normalizedMainProjectPath = (Resolve-Path $projectPath).Path + $avaloniaProjects = @($avaloniaProjects | Where-Object { (Resolve-Path $_).Path -ne $normalizedMainProjectPath }) + } + if ($avaloniaProjects.Count -eq 0) { + Write-Host "No additional net48-compatible Avalonia projects were found on this branch." -ForegroundColor Yellow + } + else { + foreach ($avaloniaProject in $avaloniaProjects) { + Invoke-MSBuild ` + -Arguments @($avaloniaProject, '/t:Restore;Build', "/p:Configuration=$Configuration", "/p:Platform=$Platform", '/v:minimal', '/nologo') ` + -Description ("Avalonia project: {0}" -f [System.IO.Path]::GetFileNameWithoutExtension($avaloniaProject)) + } + } + + $previewHostPath = Join-Path $PSScriptRoot "Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj" + if (-not (Test-Path $previewHostPath)) { + Write-Host "Avalonia preview host is not present on this branch." -ForegroundColor Yellow + Write-Host "The existing host lives on '010-advanced-entry-preview-prototype' and is net8-based, so it is outside this branch's net48-only policy." -ForegroundColor Yellow + } + elseif (-not (Test-IsNet48CompatibleProject -ProjectPath $previewHostPath)) { + Write-Host "Avalonia preview host exists but is not net48-compatible, so -BuildAvalonia skipped it." -ForegroundColor Yellow + } + } } if ($BuildInstaller -or $BuildPatch) { diff --git a/openspec/changes/avalonia-migration-roadmap/.openspec.yaml b/openspec/changes/avalonia-migration-roadmap/.openspec.yaml new file mode 100644 index 0000000000..c53ef21aaa --- /dev/null +++ b/openspec/changes/avalonia-migration-roadmap/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/avalonia-migration-roadmap/design.md b/openspec/changes/avalonia-migration-roadmap/design.md new file mode 100644 index 0000000000..d390a56372 --- /dev/null +++ b/openspec/changes/avalonia-migration-roadmap/design.md @@ -0,0 +1,123 @@ +## Context + +The recommendation from `Docs/avalonia-migration-approach-comparison.md` is **Approach 3 then +Approach 2**: run a time-boxed proof-of-concept spike, then execute the Hybrid (the lexical-edit +program as the spine, with the DataTree model/view split as the first concrete migrated region). This +roadmap encodes that recommendation as an ordered, gated sequence so the work is minimal-risk and the +two older plans stop competing. + +Two plans are reconciled: + +- **Plan A — `datatree-model-view-separation`**: splits the 4.7k-line `DataTree.cs` into a UI-agnostic + `DataTreeModel` + `SliceSpec[]` + `IDataTreeView`. Low risk, days of work, but stops at the + abstraction boundary (no Avalonia, no flag, no Graphite/native work). +- **Plan B — `lexical-edit-avalonia-migration` (+ `fieldworks-avalonia-shell-migration`)**: the + end-to-end program with typed view definitions, seams, parity automation, Graphite/native + decommissioning, the two-adapter flag, and the shell. + +The Hybrid uses B as the spine and runs A as B's first migrated region. + +## Goals / Non-Goals + +**Goals:** +- One ordered plan with explicit gates and a clear overlap resolution. +- Start with a small POC, then the densest real screen (Lexical Edit via the DataTree region). +- Keep everything behind a default-off flag with WinForms as the safe default during transition. +- Preserve functional fidelity and density; pixel-perfect is explicitly not required. + +**Non-Goals:** +- Duplicating the referenced changes' detailed requirements. +- Fixing shell timing before the regional gates are proven. + +## Decisions + +### 1. Sequence: POC → DataTree region → Lexical Edit → Shell + +**Decision:** Phase 0 is the POC spike; Phase 1 is the DataTree model/view split executed as a +migrated region; Phases 2–6 are the lexical-edit program; Phase 7+ is the shell, gated on the +regional gates. + +**Rationale:** This banks the cheapest risk reduction first (POC), then the densest, highest-value +real screen, and defers the most expensive work (shell) until the regional pattern is proven — which +is the dependency the lexical-edit program already mandates. + +### 2. Overlap resolution: A is a concrete realization of B + +**Decision:** `SliceSpec` (Plan A) is a concrete instance of the typed view-definition node (Plan B); +`IDataTreeView` (Plan A) is one of the two adapters selected by the two-adapter flag (Plan B). The +DataTree region's `AvaloniaDataTreeView` consumes the same `DataTreeModel`/`SliceSpec` the WinForms +view uses and is selected by the flag. + +**Rationale:** Avoids building two competing boundary types. The DataTree split produces the swap +point; the lexical-edit program supplies the flag, parity harness, and Graphite/native gates. + +### 3. Minimal-risk posture throughout + +**Decision:** Every phase keeps WinForms as the default, lands behind tests, and is independently +valuable and reversible. No phase deletes native Views or makes Avalonia default until that region's +manifest gates pass. + +## Master sequence and gates + +```mermaid +flowchart TB + subgraph P0["Phase 0 — POC spike (lexical-edit-avalonia-poc-spike)"] + direction LR + A0["Flag + in-proc host bridge
one slice (3 editors)
density/parity evidence"]:::poc + end + subgraph P1["Phase 1 — DataTree region (datatree-model-view-separation)"] + direction LR + A1["Char. tests → partial split →
extract collaborators →
DataTreeModel + SliceSpec + IDataTreeView"]:::region + end + subgraph P2["Phases 2–6 — Lexical Edit program (lexical-edit-avalonia-migration)"] + direction LR + A2["Seams → typed IR + XML import →
Avalonia editors/tables →
parity + Graphite/native gates"]:::spine + end + subgraph P7["Phase 7+ — Shell (fieldworks-avalonia-shell-migration)"] + direction LR + A7["Shell contracts → Avalonia shell →
screen migration → default switch"]:::shell + end + + G0{"Gate 0
host bridge proven +
density acceptable +
flag dual-run works"}:::gate + G1{"Gate 1
AvaloniaDataTreeView at
semantic+density parity,
behind flag, no native/Graphite"}:::gate + G2{"Gate 2
Lexical Edit region complete:
parity, native audit clean,
Graphite-free default"}:::gate + + P0 --> G0 --> P1 --> G1 --> P2 --> G2 --> P7 + + classDef poc fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef region fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef spine fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef shell fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef gate fill:#fee2e2,stroke:#b91c1c,color:#450a0a; +``` + +### Gate definitions + +- **Gate 0 (POC → region):** in-process net48 host bridge proven (or fallback recorded); density + delta acceptable at 100% and 150% DPI; the same build runs either surface behind the flag; + `spike-evidence.md` gives go. +- **Gate 1 (region → program):** `AvaloniaDataTreeView` implements `IDataTreeView`, consumes the same + `DataTreeModel`/`SliceSpec` as WinForms, is selected by the two-adapter flag, matches the semantic + + density baseline within tolerance, and instantiates no native Views or Graphite at runtime. +- **Gate 2 (program → shell):** the Lexical Edit region manifest passes — semantic parity, UIA2 legacy + baselines, Avalonia.Headless tests, render-comparison evidence, native-viewing audit clean, and no + unapproved Graphite/native-rendering default-path dependency. + +## Overlap map (vocabulary reconciliation) + +```mermaid +flowchart LR + SS["SliceSpec (Plan A)"]:::a -->|is a concrete| IRN["Typed view-definition node (Plan B)"]:::b + IDV["IDataTreeView (Plan A)"]:::a -->|is one adapter of| FLAG["Two-adapter flag (Plan B)"]:::b + DM["DataTreeModel (Plan A)"]:::a -->|feeds| ER["ILexicalEditorRegistry (Plan B)"]:::b + CT["Characterization tests (Plan A Phase 0)"]:::a -->|extend| PA["Parity automation (Plan B)"]:::b + classDef a fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef b fill:#dcfce7,stroke:#16a34a,color:#052e16; +``` + +## Risk controls + +- WinForms stays the default until each region's gate passes; the flag default is WinForms. +- Each phase is independently valuable and reversible; stalling at any phase still leaves value. +- No native Views deletion or Graphite default-path removal until the region manifest proves it. +- The POC converts the roadmap's remaining estimates into measured numbers before the region starts. diff --git a/openspec/changes/avalonia-migration-roadmap/proposal.md b/openspec/changes/avalonia-migration-roadmap/proposal.md new file mode 100644 index 0000000000..98ca21712c --- /dev/null +++ b/openspec/changes/avalonia-migration-roadmap/proposal.md @@ -0,0 +1,56 @@ +## Why + +Two planning sets exist for moving FieldWorks to Avalonia: an older DataTree model/view separation +(`datatree-model-view-separation`) and a newer end-to-end program +(`lexical-edit-avalonia-migration` + `fieldworks-avalonia-shell-migration`). They overlap and need a +single, ordered roadmap so the team works one plan, not three. The goal is to move the **whole** +application to Avalonia, starting with the main Lexical Edit view after a small proof of concept, +preserving **functional fidelity and density** (not pixel-perfect), with the new path behind a +**feature flag** so the same build runs either Avalonia or the legacy WinForms controls. + +This change is the **umbrella roadmap**. It does not introduce code. It sequences the existing +changes into one minimal-risk path, defines the gates between them, and resolves the overlap between +the two plans (the DataTree split becomes the concrete first migrated region inside the lexical-edit +program). + +## What Changes + +- Adopt the **Hybrid** approach: the lexical-edit program is the spine (typed view definition, + seams, parity automation, Graphite/native decommissioning, two-adapter flag, then the shell); the + DataTree model/view split is executed as the **first concrete migrated region** inside it. +- Add a **proof-of-concept spike** (`lexical-edit-avalonia-poc-spike`) as the entry point, before the + regional migration, to de-risk the host bridge, fidelity/density, and the dual-run flag. +- Define the **ordered sequence and gates** across all changes so no phase starts before its + predecessor's evidence exists. +- Reconcile vocabulary between the two plans: Plan A's `SliceSpec` is a concrete realization of Plan + B's typed view-definition node; Plan A's `IDataTreeView` is selected by Plan B's two-adapter flag. +- Keep the comparison analysis (`Docs/avalonia-migration-approach-comparison.md`) as the rationale of + record for choosing the Hybrid. + +## Non-goals + +- Re-deriving or duplicating the detailed requirements already captured in the referenced changes. +- Changing any default runtime behavior in this change (the roadmap is planning only). +- Committing to shell migration timing before the regional Lexical Edit gates are proven. +- Reopening the frozen seam decisions; this roadmap consumes them. + +## Capabilities + +### New Capabilities + +- `avalonia-migration-roadmap`: The ordered, gated sequence and overlap resolution that governs how + the proof-of-concept, DataTree region split, lexical-edit migration, and shell migration proceed. + +## Referenced changes (not duplicated here) + +- `lexical-edit-avalonia-poc-spike` — the entry-point proof of concept (this roadmap's Phase 0). +- `datatree-model-view-separation` — the first concrete migrated region (this roadmap's Phase 1). +- `lexical-edit-avalonia-migration` — the regional program spine (Phases 2–6). +- `fieldworks-avalonia-shell-migration` — the application-wide shell migration (Phase 7+), gated. +- `detail-controls-testability`, `retire-linux-era-view-shims`, `render-speedup-benchmark` — + supporting/companion work that reduces risk but does not gate the main sequence. + +## Impact + +- Planning/process only. No source code, native code, or packaging changes in this change. +- Subsequent code impact is described in the referenced changes' own proposals and specs. diff --git a/openspec/changes/avalonia-migration-roadmap/specs/avalonia-migration-roadmap/spec.md b/openspec/changes/avalonia-migration-roadmap/specs/avalonia-migration-roadmap/spec.md new file mode 100644 index 0000000000..109193f4ad --- /dev/null +++ b/openspec/changes/avalonia-migration-roadmap/specs/avalonia-migration-roadmap/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: The migration follows one ordered, gated sequence + +The Avalonia migration SHALL proceed in the order proof-of-concept spike, DataTree migrated region, +Lexical Edit program, then shell, with explicit gates between phases. A phase SHALL NOT start before +its predecessor's gate evidence exists. + +#### Scenario: A phase is blocked without its gate +- **WHEN** a later phase is proposed to start +- **THEN** the preceding gate's evidence (POC evidence, region parity, or regional manifest) SHALL be + present and passing before that phase begins + +#### Scenario: Shell waits for the regional gates +- **WHEN** shell migration work is scheduled +- **THEN** it SHALL NOT default to Avalonia until the Lexical Edit regional gate (Gate 2) passes + +### Requirement: WinForms remains the default until each region's gate passes + +During the transition the same build SHALL run either Avalonia or the legacy WinForms controls behind +a feature flag, with WinForms as the default until a region's completion gate passes. + +#### Scenario: Default path is WinForms during transition +- **WHEN** a build is produced before a region's gate passes +- **THEN** the default runtime path for that region SHALL be the WinForms controls +- **AND** the Avalonia path SHALL be reachable only by enabling the flag + +### Requirement: The DataTree split is the first migrated region + +The DataTree model/view separation SHALL be executed as the first concrete migrated region of the +Lexical Edit program, not as a standalone end state. + +#### Scenario: SliceSpec and IDataTreeView align to the program seams +- **WHEN** the DataTree region is implemented +- **THEN** `SliceSpec` SHALL be a concrete realization of the typed view-definition node +- **AND** `IDataTreeView` SHALL be one of the adapters selected by the two-adapter flag +- **AND** `AvaloniaDataTreeView` SHALL consume the same `DataTreeModel`/`SliceSpec` as the WinForms view + +### Requirement: Functional fidelity and density are the parity target + +Each migrated region SHALL be judged on functional fidelity and information density to near-pixel +tolerance, not on pixel-perfect reproduction. + +#### Scenario: Region completion uses semantic and density evidence +- **WHEN** a region is proposed as complete +- **THEN** its evidence SHALL include a normalized semantic snapshot comparison and density + measurements, with every difference classified rather than requiring identical pixels diff --git a/openspec/changes/avalonia-migration-roadmap/tasks.md b/openspec/changes/avalonia-migration-roadmap/tasks.md new file mode 100644 index 0000000000..01de5241ac --- /dev/null +++ b/openspec/changes/avalonia-migration-roadmap/tasks.md @@ -0,0 +1,34 @@ +# Tasks + +> This change is planning/sequencing only. "Done" means the roadmap, gates, and overlap resolution +> are recorded and the referenced changes are aligned to them. No runtime code here. + +## 1. Establish the roadmap and gates + +- [x] 1.1 Record the Hybrid decision and rationale (`Docs/avalonia-migration-approach-comparison.md`). +- [x] 1.2 Define the ordered sequence POC → DataTree region → Lexical Edit → Shell with gates 0–2 + (`design.md`). +- [x] 1.3 Define the overlap resolution (SliceSpec ⊂ typed IR; IDataTreeView ⊂ two-adapter flag) + (`design.md`, and `datatree-model-view-separation/hybrid-alignment.md`). + +## 2. Stand up the entry-point change + +- [x] 2.1 Create the `lexical-edit-avalonia-poc-spike` change (proposal, design, tasks, spec, + test-plan) as Phase 0. +- [ ] 2.2 Confirm the POC flag, host-bridge, slice, and parity tasks match Gate 0 before the spike + starts. + +## 3. Align the referenced changes to the sequence + +- [ ] 3.1 Add the hybrid-alignment note to `datatree-model-view-separation` marking it as Phase 1 (the + first migrated region) and pointing `IDataTreeView`/`SliceSpec` at the lexical-edit seams. +- [ ] 3.2 Confirm `lexical-edit-avalonia-migration` Phase 3+ tasks consume the DataTree region output + rather than redefining a parallel boundary. +- [ ] 3.3 Confirm `fieldworks-avalonia-shell-migration` remains gated on Gate 2 and does not start + before the regional gates pass. + +## 4. Validation + +- [ ] 4.1 Verify each referenced change's gate definitions are consistent with gates 0–2 here. +- [ ] 4.2 Keep this roadmap updated as the POC evidence lands and estimates firm up. +- [ ] 4.3 Run `CI: Full local check` before commit/push of roadmap and aligned docs. diff --git a/openspec/changes/datatree-model-view-separation/.openspec.yaml b/openspec/changes/datatree-model-view-separation/.openspec.yaml new file mode 100644 index 0000000000..e331c975d9 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-25 diff --git a/openspec/changes/datatree-model-view-separation/datatree-mental-model.md b/openspec/changes/datatree-model-view-separation/datatree-mental-model.md new file mode 100644 index 0000000000..f169b2e9d6 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/datatree-mental-model.md @@ -0,0 +1,261 @@ +# DataTree Mental Model + +## Scope and status + +This document explains the **current** runtime architecture around `DataTree` in FieldWorks and how the proposed model/view split would change it. + +Current-state summary: + +- `DataTree` is the central WinForms detail editor engine (`Src/Common/Controls/DetailControls/DataTree.cs`). +- `RecordEditView` is the primary host/orchestrator (`Src/xWorks/RecordEditView.cs`). +- `DTMenuHandler` drives many edit commands using the current slice/object (`Src/xWorks/DTMenuHandler.cs`). +- `SliceFactory` converts XML editor tokens into concrete slice/control types (`Src/Common/Controls/DetailControls/SliceFactory.cs`). +- `ObjSeqHashMap` handles strict and type-based slice reuse (`Src/Common/Controls/DetailControls/ObjSeqHashMap.cs`). +- `IDataTreePainter` exists as a view seam for line painting. +- Proposed `DataTreeModel` / `IDataTreeView` / `SliceSpec` are **not implemented yet** in production code on this branch. + +--- + +## Big picture: what problem this subsystem solves + +`DataTree` takes: + +1. LCM domain data (`ICmObject`, `LcmCache`, metadata/flids), +2. XML layout/part definitions (`Inventory` lookups), and +3. runtime UI state (`PropertyTable`, current selection, show-hidden toggles), + +and produces a **live editable tree of WinForms controls** (“slices”) that users can navigate and edit. + +So this is a transformation pipeline from: + +`Domain object graph + XML config + UI state` → `slice list + controls + interaction state` + +--- + +## Main data pathways and conversions + +## 1) View initialization pathway (host wiring) + +Entry point: `RecordEditView.SetupDataContext()`. + +What happens: + +1. Creates persistence context key (per vector/view). +2. Assigns `PersistenceProvider` into `DataTree`. +3. Loads layout and part inventories via `Inventory.GetInventory("layouts", db)` and `Inventory.GetInventory("parts", db)`. +4. Calls `DataTree.Initialize(cache, layouts, parts)` and `DataTree.Init(mediator, propertyTable, config)`. +5. Installs slice filter and context-menu handler. +6. Hosts DataTree in WinForms control tree. + +Conversions: + +- XML inventory files → `Inventory` in-memory indexes. +- View config XML (`m_configurationParameters`) → runtime context (`layout`, `layoutChoiceField`, `filterPath`, persistence key). + +--- + +## 2) Object-to-slices pathway (core render pipeline) + +Entry point: `DataTree.ShowObject(root, layoutName, layoutChoiceField, descendant, suppressFocusChange)`. + +What happens: + +1. Loads show-hidden flags from `PropertyTable` (keyed by current tool). +2. Restores/snapshots current-slice identity metadata. +3. Decides create vs refresh: + - root changed → `CreateSlices(true)` + - same root, descendant changed → adjust target slice + - otherwise → `RefreshList(false)` +4. Schedules focus restoration via mediator idle queue. + +`CreateSlices(...)` then: + +1. Builds reuse map from current slices (`ObjSeqHashMap`). +2. Calls `CreateSlicesFor(...)` recursively to materialize new/updated slices. +3. Removes unused slices, reapplies tooltip state, resets tab indices. + +Recursive layout pipeline: + +- `CreateSlicesFor` chooses template via `GetTemplateForObjLayout`. +- `ApplyLayout` iterates layout child nodes. +- `ProcessPartRefNode` resolves `` / ``. +- `ProcessSubpartNode` dispatches to: + - `AddSimpleNode` (``) + - `AddSeqNode` (``) + - `AddAtomicNode` (``) + +Key conversions in this pipeline: + +- `ICmObject` + field name strings → FLIDs (`GetFieldId2`) and HVOs. +- XML nodes (`layout`, `part`, `slice`, `seq`, `obj`) → slice construction decisions. +- Sequence properties (`VecProp`) → managed `int[]` HVO arrays (`SetupContents`). +- Editor token string (`editor="..."`) → concrete slice class via `SliceFactory.Create`. +- Path stack (`XmlNode` + HVO sequence) → `slice.Key` object[] (identity/reuse key). + +--- + +## 3) Editing pathway (user interaction → domain update) + +The right-side control in each `Slice` edits LCM-backed properties. + +Typical flow: + +1. User edits a field in a slice control. +2. Control writes through LCM interfaces (`DomainDataByFlid` / object API). +3. `DataTree` receives property change notifications (for monitored `(hvo, flid)` pairs). +4. `PropChanged` decides refresh strategy: + - monitored property: `RefreshListAndFocus` (possibly deferred via `BeginInvoke`) + - undo/redo structural changes: force broader refresh and focus update +5. `RefreshList(...)` reuses slices where possible and rebinds current slice. + +Important state machine pieces: + +- `DoNotRefresh` + `RefreshListNeeded` + `m_fPostponedClearAllSlices` +- `m_postponePropChanged` (defer refresh to avoid re-entrancy crashes) +- current-slice snapshot fields (`m_currentSlicePartName`, `m_currentSliceObjGuid`, etc.) + +--- + +## 4) Menu-command pathway (DTMenuHandler) + +`DTMenuHandler` is tightly coupled to `DataTree`/`CurrentSlice`. + +What it does: + +- Reads `CurrentSlice`, `Root`, `Slices`, and sometimes `FieldAt(...)`. +- Runs commands like insert/copy/move/delete/edit/add-reference. +- Uses `FindMatchingSlices` and current object/slice identity to keep selection stable after mutations. + +This is a major coupling seam that the future split must preserve via stable facade behavior. + +--- + +## 5) Persistence/customization pathway + +Two persistence layers are active: + +1. **UI/session properties** (`PropertyTable` + `PersistenceProvider`) + - show-hidden toggles + - splitter base distance (`PersistPreferences`/`RestorePreferences`) + - current-slice recall keys + +2. **Layout overrides** (`Inventory.PersistOverrideElement`) + - custom field placeholder corrections + - generated/modified layout nodes persisted to override storage + +So user-visible structure is partly from shipped XML and partly from persisted overrides. + +--- + +## 6) Painting/layout pathway (WinForms-specific view mechanics) + +`DataTree` remains a WinForms view container: + +- performs layout and scroll behavior for slices, +- manages splitter/indent positioning, +- paints separator lines (`Painter.PaintLinesBetweenSlices(...)`), +- coordinates focus and tab ordering. + +This view work is intentionally platform-specific and a good candidate to remain in a view class after split. + +--- + +## 7) Sequence handling and lazy materialization + +In large sequences (`kInstantSliceMax = 20`), `AddSeqNode` may create `DummyObjectSlice` placeholders instead of eagerly creating full slice subtrees. + +Why: + +- startup/render cost control, +- preserving responsiveness, +- deferring materialization until needed. + +This behavior is subtle and test-sensitive; it should be preserved or intentionally redesigned with explicit contracts. + +--- + +## Module-by-module responsibilities and held data + +| Module | Performs | Holds key data/state | +|---|---|---| +| `RecordEditView` | Host/orchestrator for DataTree lifecycle; wires inventories, mediator, filters, menu handler | `m_dataEntryForm`, layout names, config XML, persistence context | +| `DataTree` | End-to-end pipeline from object/layout to editable slice controls; refresh, selection, messaging, paint/layout | cache, metadata cache, inventories, current root/descendant/slice, monitored props set, show-hidden flag, refresh deferral flags | +| `Slice` (+ subclasses) | Row abstraction: left label/tree node + right editor control; local UX behavior | object context, flid/config nodes, key path, parent slice, mediator/property table/cache refs | +| `SliceFactory` | Maps editor tokens and context into concrete slice classes | conversion logic from XML attributes (`editor`, `ws`, etc.) to constructors | +| `ObjSeqHashMap` | Reuse index for slices by strict path key and by type name | hashtable keyed by path lists, type-name reusable lists | +| `Inventory` (XCore) | Lookup/unify/persist XML layouts/parts | in-memory index of XML elements and persisted override handling | +| `DTMenuHandler` | Command and context-menu logic tied to current DataTree selection | `m_dataEntryForm`, command context, temporary move/copy state | +| `StTextDataTree` (`InfoPane`) | Specialized DataTree behavior for interlinear text roots/selection | root transformation logic before base `ShowObject` | + +--- + +## Where native `Src/views` / “Views.cpp” fits + +I found **no direct references** between `DataTree`/`RecordEditView`/`DTMenuHandler` and the native `Src/views` C++ layer in this branch (symbol search in `Src/views/**` for those types is empty). + +Interpretation: + +- This specific DataTree pipeline is primarily managed WinForms + LCM + XML inventory logic. +- Native `Src/views` still underpins rendering/editor infrastructure elsewhere in FieldWorks, but this proposed split should not require direct C++ `views` API changes unless a managed contract currently hiding that dependency is altered. + +Practical impact expectation: **minimal/no direct `Src/views` code impact** for the planned DataTree model/view split. + +--- + +## Is the split plan sensible? + +Short answer: **yes**, with the right boundary. + +Why it is sensible: + +1. Current class mixes three concerns: decision logic, UI materialization, and UI rendering. +2. XML + domain traversal logic is deterministic and testable without WinForms. +3. Existing painter seam (`IDataTreePainter`) already demonstrates successful decoupling in one area. +4. Host and menu consumers can keep using a stable DataTree facade while internals move. + +Main risk: + +- preserving behavior around slice reuse keys, deferred refresh semantics, and lazy dummy-slice expansion. + +Mitigation: + +- characterization tests around those exact behaviors before/through extraction. + +--- + +## How it would look after split (target shape) + +## Target architecture + +- **DataTreeModel (no WinForms):** + - owns `ShowObject` decision state, monitored properties, and XML/layout traversal outputs + - emits framework-agnostic slice descriptors (`SliceSpec`) +- **SliceLayoutBuilder (pure logic collaborator):** + - owns `CreateSlicesFor` / `ApplyLayout` / `ProcessSubpartNode` style traversal logic + - converts `(ICmObject + XmlNode + metadata)` → `SliceSpec` graph/list +- **DataTreeView (WinForms DataTree facade):** + - materializes specs into concrete `Slice` controls via factory + - owns WinForms layout, paint, scroll, splitter, focus plumbing +- **Reuse manager (existing ObjSeqHashMap semantics):** + - preserved in view/materialization layer, keyed by spec path identity + +## Data flow then + +1. Host asks model to build specs. +2. Model reads cache + inventory + property state, returns ordered `SliceSpec` list. +3. View diffs specs against existing controls using reuse map. +4. View creates/rebinds/positions controls and restores focus/scroll. + +This keeps “what to show” independent of UI toolkit while preserving existing behavior contracts for consumers. + +--- + +## Suggested incremental extraction order + +1. Extract `SliceLayoutBuilder` methods first (highest complexity, purest logic value). +2. Keep DataTree as façade that delegates into builder but still materializes controls. +3. Introduce `SliceSpec` output mode and adapter in DataTree. +4. Introduce `DataTreeModel` orchestrating selection/refresh decisions. +5. Keep `DTMenuHandler` and `RecordEditView` APIs stable until final phase. + +This sequence reduces risk while gradually introducing the end-state boundaries. diff --git a/openspec/changes/datatree-model-view-separation/design.md b/openspec/changes/datatree-model-view-separation/design.md new file mode 100644 index 0000000000..68db9e9be1 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/design.md @@ -0,0 +1,149 @@ +## Context + +DataTree.cs is currently ~4.7k lines implementing a WinForms `UserControl` that fuses 8+ responsibilities into one class. It is the core detail-editing control in FieldWorks — every entry, sense, and record is displayed through DataTree slices. The Avalonia migration roadmap requires that both WinForms and Avalonia coexist in the same repo, meaning we need a UI-framework-agnostic model layer either can consume. + +**Current architecture:** +``` +RecordEditView → DataTree (monolith: XML parsing + WinForms layout + navigation + messaging + persistence) + ↕ + Slice (3,341 lines, also monolithic) + ↕ + SliceFactory, ObjSeqHashMap, SliceFilter +``` + +**Key coupling points:** +- `Slice.ContainingDataTree` — every Slice holds a reference to the parent DataTree +- `SliceFactory.Create()` — returns WinForms `Slice` objects directly +- `StTextDataTree : DataTree` — the only subclass, in InfoPane.cs +- `DTMenuHandler.m_dataEntryForm` — holds a typed `DataTree` reference +- `RecordEditView.m_dataEntryForm` — the primary consumer + +**Current test reality (branch snapshot):** DataTree tests are now spread across `DataTreeTests.cs` plus `DataTreeTests.Wave3.*` and `DataTreeTests.Wave4.OffscreenUI.cs`, including offscreen paint/layout and command/navigation paths. Coverage has improved from the original baseline, but production `DataTree` remains monolithic. + +**Current implementation snapshot (2026-02-28):** `IDataTreePainter` is implemented; `DataTreeModel`, `IDataTreeView`, `SliceLayoutBuilder`, and `ShowHiddenFieldsManager` are not yet present in production code. + +## Goals / Non-Goals + +**Goals:** +- Separate "what slices to show" (model) from "how to render them" (view) so the model layer can be shared between WinForms and Avalonia +- Make the XML-to-slice pipeline testable without WinForms infrastructure +- Reduce DataTree.cs to a manageable size where any developer can understand each file's purpose +- Preserve exact runtime behavior throughout — zero functional regressions +- Enable incremental delivery: each phase ships independently and is valuable on its own + +**Non-Goals:** +- Building an Avalonia `DataTreeView` — that is a separate future change +- Refactoring Slice.cs internals beyond partial-class splitting +- Changing the XML layout/part format or Inventory loading +- Modifying DTMenuHandler or RecordEditView beyond minimal adaptation +- Performance optimization (the reuse map is complex enough; preserve it as-is) +- Replacing the XCore Mediator pattern + +## Decisions + +### Decision 1: Four-phase incremental delivery + +**Choice:** Deliver in 4 sequential phases, each independently mergeable. + +**Phases:** +1. **Characterization tests** (Phase 0) — add ~20 tests covering current behavior +2. **Partial-class split** (Phase 1) — mechanical file decomposition, zero logic changes +3. **Extract collaborators** (Phase 2) — `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `SliceCollection` as composition targets inside DataTree +4. **Model/View separation** (Phase 3) — introduce `DataTreeModel`, `SliceSpec`, `IDataTreeView`; DataTree becomes a thin WinForms view + +**Rationale:** Each phase is valuable alone. Phase 0 provides a safety net. Phase 1 improves readability immediately. Phase 2 enables unit testing of the most complex logic. Phase 3 unlocks Avalonia. If the project stalls at any phase, the completed work still has value. + +**Alternative considered:** Big-bang model/view extraction in one PR. Rejected because DataTree touches every detail view in the application — risk of subtle regressions is too high without phased safety nets. + +### Decision 2: SliceSpec as intermediate representation + +**Choice:** Introduce a `SliceSpec` class (plain C# object, no WinForms dependency) that captures everything needed to create a slice: label, abbreviation, indent, editor type, XML config, field ID, object, visibility, weight, tooltip, key path. + +**Rationale:** The current code creates `Slice` WinForms controls directly inside the XML parsing loop (`AddSimpleNode` → `SliceFactory.Create`). This is the fundamental coupling that prevents model/view separation. `SliceSpec` breaks this dependency: the model produces specs, the view materializes them. + +**Alternative considered:** Having the model produce abstract `ISlice` interfaces. Rejected because `ISlice` would still need to carry WinForms `Control` semantics and the interface would grow as large as the concrete class. + +### Decision 3: SliceFactory stays in the view layer (Phase 3) + +**Choice:** `SliceFactory.Create()` remains in the view layer and accepts `SliceSpec` instead of raw XML nodes. The model layer's `SliceLayoutBuilder` produces `SliceSpec` lists; the view layer calls `SliceFactory.Create(spec)` to materialize them. + +**Rationale:** SliceFactory creates ~30 concrete WinForms slice types. Moving it to the model layer would require abstracting all those types. Keeping it in the view layer means only `SliceSpec` crosses the boundary, and each platform (WinForms, Avalonia) has its own factory. + +**Alternative considered:** A fully abstract factory with platform adapters. Deferred to the Avalonia implementation phase since we don't yet know what Avalonia slice equivalents look like. + +### Decision 4: Keep ObjSeqHashMap in the view layer + +**Choice:** The slice reuse map (`ObjSeqHashMap`) stays in the view layer because it maps `SliceSpec.Key` → existing `Slice` instances. The model layer does not concern itself with reuse optimization. + +**Rationale:** Reuse is a performance optimization that depends on having concrete control instances. The model layer simply produces a fresh `SliceSpec` list on each `ShowObject`/`RefreshList` call. The view layer diffs against existing slices using `ObjSeqHashMap`. + +### Decision 5: StTextDataTree adaptation strategy + +**Choice:** `StTextDataTree` currently overrides `ShowObject` to transform the root object (CmBaseAnnotation → owner Text) before calling `base.ShowObject`. In Phase 3, this becomes a model-layer hook: `DataTreeModel` gains a virtual `ResolveRootObject(ICmObject) → ICmObject` method that `StTextDataTree` overrides via a custom `DataTreeModel` subclass or a delegate injection. + +**Rationale:** The override is purely about object resolution (model concern), not rendering. Moving it to the model layer is both natural and simple. + +### Decision 6: Composition over inheritance for DataTreeModel + +**Choice:** `DataTreeModel` uses composition — it holds `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, and a `SliceCollection` (state tracker). These are injected via constructor or property. + +**Rationale:** DataTree's current design suffers from being a single class that inherited too many responsibilities. Composition makes each piece independently testable and replaceable. The only inheritance left is `DataTree : UserControl` (required by WinForms) and the `StTextDataTree` subclass. + +## Risks / Trade-offs + +**[Risk] SliceSpec may not capture all slice creation context** → Mitigation: Phase 2 extracts `SliceLayoutBuilder` *before* introducing `SliceSpec`, so we learn exactly what information flows from XML parsing to slice creation. `SliceSpec` is designed after that extraction, informed by real data. + +**[Risk] ObjSeqHashMap reuse breaks during model/view split** → Mitigation: Phase 0 characterization tests verify reuse behavior. Phase 3 preserves the exact key structure (`Slice.Key` → `SliceSpec.Key`). + +**[Risk] Subtle behavior differences after partial-class split** → Mitigation: This is a zero-logic change; the compiler guarantees identical IL. Characterization tests from Phase 0 provide additional confidence. + +**[Risk] DTMenuHandler and other consumers reference concrete DataTree** → Mitigation: Phase 3 keeps the concrete `DataTree` class; it just becomes thinner. Consumers don't need to change their references. `IDataTreeView` is used only by `DataTreeModel` internally. + +**[Risk] Phase 3 model/view split is large** → Mitigation: Phase 2 has already extracted 60% of the logic into collaborators. Phase 3 is primarily about moving those collaborators under `DataTreeModel` and introducing `SliceSpec` as the boundary type. + +**[Risk] Performance regression from SliceSpec indirection** → Mitigation: `SliceSpec` is a lightweight data object (no allocations beyond the object itself). The expensive work (XML parsing, cache queries) happens exactly once regardless of whether the result is a `Slice` or a `SliceSpec`. + +## Phased Implementation Plan + +### Phase 0: Characterization Tests (1-2 days, low risk) + +- Add ~20 tests to `DataTreeTests.cs` covering: XML→slice mapping, show-hidden toggle, slice reuse, PropChanged routing, navigation, DummyObjectSlice expansion, SliceFilter interaction +- Extend `Test.fwlayout` and `TestParts.xml` with test layouts for sequence properties (>20 items), nested headers, visibility="never" parts +- No production code changes +- **Exit criterion:** All new tests pass; no existing tests broken + +### Phase 1: Partial-Class Split (1 day, very low risk) + +- Split `DataTree.cs` into 7 partial-class files per the spec +- Split `Slice.cs` into 4-5 partial-class files +- Update `.csproj` if needed (SDK-style projects auto-include; verify) +- **Exit criterion:** `build.ps1` succeeds; all tests pass; `git diff --stat` shows only file renames/splits + +### Phase 2: Extract Collaborators (3-5 days, low-medium risk) + +Order of extraction (most valuable first): +1. **`SliceLayoutBuilder`** — extract `CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, `AddAtomicNode`, `EnsureCustomFields`, label/weight helpers. DataTree delegates to it. ~1,000 lines moved. +2. **`ShowHiddenFieldsManager`** — extract `GetShowHiddenFieldsToolName`, `HandleShowHiddenFields`, key resolution. ~100 lines. Already well-tested from LT-22427. +3. **`DataTreeNavigator`** — extract `CurrentSlice` logic, goto methods, focus helpers. ~150 lines. + +Each extraction is a separate commit with its own test run. At this point, `SliceLayoutBuilder` can be unit-tested by providing mock `Inventory`, `LcmCache`, and `IFwMetaDataCache` — no `Form` required. + +- **Exit criterion:** All characterization tests pass; `SliceLayoutBuilder` has dedicated unit tests; DataTree.cs is under 2,000 lines. + +### Phase 3: Model/View Separation (5-8 days, medium risk) + +1. Define `SliceSpec` data class +2. Define `IDataTreeView` interface +3. Create `DataTreeModel` composing `SliceLayoutBuilder` + `ShowHiddenFieldsManager` + navigation state +4. Modify `SliceLayoutBuilder` to produce `SliceSpec[]` instead of calling `SliceFactory.Create` directly +5. Modify `DataTree` to implement `IDataTreeView`: receive `SliceSpec[]` from model, call `SliceFactory.Create(spec)` to materialize, manage `ObjSeqHashMap` for reuse +6. Adapt `StTextDataTree` to override model-layer hook +7. Verify `RecordEditView` and `DTMenuHandler` still function (should require no changes if DataTree facade API is preserved) + +- **Exit criterion:** All characterization tests pass; `DataTreeModel` has no `System.Windows.Forms` reference; existing integration tests pass; manual smoke test of LexEdit entry display + +## Open Questions + +- Should `SliceSpec` be a record type (C# 9+) or a plain class? Depends on .NET Framework 4.8 language version constraints. +- Should the `IDataTreeView` interface live in the DetailControls assembly or a separate abstraction assembly? If Avalonia implementation will be in a different project, a shared abstractions package may be needed. +- How should `DummyObjectSlice` lazy expansion interact with `SliceSpec`? The model could produce placeholder specs, or the view could handle laziness entirely. diff --git a/openspec/changes/datatree-model-view-separation/hybrid-alignment.md b/openspec/changes/datatree-model-view-separation/hybrid-alignment.md new file mode 100644 index 0000000000..3db59a3cf3 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/hybrid-alignment.md @@ -0,0 +1,39 @@ +# Hybrid Alignment: DataTree split as the first migrated region + +This change (`datatree-model-view-separation`) is sequenced by `avalonia-migration-roadmap` as +**Phase 1 — the first concrete migrated region** of the Lexical Edit Avalonia program +(`lexical-edit-avalonia-migration`). It is no longer a standalone end state that "stops at the +abstraction boundary"; it is the swap point that the program's two-adapter feature flag selects. + +## What changes about this plan's framing + +The four phases (characterization tests → partial split → extract collaborators → model/view split) +are unchanged. What changes is where the boundary types connect: + +| This change (Plan A) | Aligns to (program / Plan B) | +|----------------------|------------------------------| +| `SliceSpec` | A concrete realization of the **typed view-definition node**. Keep it lightweight; it is the DataTree region's instance of the program IR, not a competing type. | +| `IDataTreeView` | One of the **two adapters** selected by the program's feature flag (`FW_AVALONIA_LEXEDIT`). `DataTree` (WinForms) and `AvaloniaDataTreeView` both implement it. | +| `DataTreeModel` | Feeds the program's `ILexicalEditorRegistry`/refresh seams; it is UI-agnostic and shared by both adapters. | +| Phase 0 characterization tests | Extend the program's **parity automation** (semantic snapshot + density), so the same baseline gates the Avalonia view. | + +## Sequencing and gates + +- **Enter** this region only after the POC spike (`lexical-edit-avalonia-poc-spike`) passes **Gate 0** + (host bridge proven, density acceptable, dual-run flag works). +- **Exit** this region at **Gate 1**: `AvaloniaDataTreeView` consumes the same + `DataTreeModel`/`SliceSpec` as WinForms, is selected by the flag, matches the semantic + density + baseline within tolerance, and instantiates no native Views or Graphite at runtime. +- WinForms `DataTree` stays the **default** for this region until Gate 1 passes. + +## Practical effect on Phase 3 + +Phase 3 (model/view split) should produce `IDataTreeView` and `SliceSpec` with the program seams in +mind: do not invent a parallel boundary vocabulary, and leave a clear seam for +`AvaloniaDataTreeView : IDataTreeView` to be added behind the flag. The previously-open Phase 3 +questions (SliceFactory location, `DummyObjectSlice` lazy expansion, `StTextDataTree` override) are +resolved in favor of whatever keeps `SliceSpec` a faithful concrete instance of the program's typed +node. + +See `Docs/avalonia-migration-approach-comparison.md` for the full rationale and +`openspec/changes/avalonia-migration-roadmap/design.md` for the master sequence and gate definitions. diff --git a/openspec/changes/datatree-model-view-separation/proposal.md b/openspec/changes/datatree-model-view-separation/proposal.md new file mode 100644 index 0000000000..d0bdb22cdf --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/proposal.md @@ -0,0 +1,44 @@ +## Why + +DataTree.cs (currently ~4.7k lines on this branch) is a God Class that fuses XML layout parsing, slice lifecycle management, WinForms layout/paint, focus navigation, mediator messaging, data-change notification, and persistence into a single `UserControl`. This makes it difficult to reason about in isolation and blocks straightforward reuse when the project migrates from WinForms to Avalonia. The same problem extends to Slice.cs. With the Avalonia migration on the roadmap, we need a UI-framework-agnostic model layer so both WinForms and Avalonia views can coexist during the transition period. + +### Current implementation snapshot (2026-02-28) + +- `IDataTreePainter` exists and is implemented by `DataTree`; painter seams are in place for offscreen/UI-adjacent tests. +- `DataTreeModel`, `IDataTreeView`, `SliceLayoutBuilder`, and `ShowHiddenFieldsManager` are **not** present yet in production code. +- `DataTree` remains a single concrete WinForms control file (`DataTree.cs`) rather than partial-class decomposition in production. + +## What Changes + +- **Extract `DataTreeModel`** — a new class (no WinForms dependency) owning XML layout interpretation, slice-spec construction, show-hidden-fields resolution, property-change routing, and navigation state. This is the "what to display" layer. +- **Extract `SliceLayoutBuilder`** — moves `CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, `AddAtomicNode`, `EnsureCustomFields` out of DataTree into a focused collaborator consumed by `DataTreeModel`. +- **Introduce `SliceSpec`** — a UI-framework-agnostic descriptor produced by `SliceLayoutBuilder`. Each `SliceSpec` captures label, indent, editor type, XML config, field ID, and object reference — everything needed for a view layer to materialize a concrete control. +- **Introduce `IDataTreeView`** — an interface that WinForms `DataTree` (and later Avalonia) implements, receiving `SliceSpec` lists from `DataTreeModel` and materializing them into platform controls. +- **Slim `DataTree`** to a WinForms `UserControl` that implements `IDataTreeView`: layout, paint, splitter management, control hosting. It delegates all "what" decisions to `DataTreeModel`. +- **Add characterization tests** (Phase 0) covering XML→slice-list mapping, show-hidden toggle, slice reuse, PropChanged→refresh, navigation, and DummyObjectSlice expansion — all *before* any structural changes. +- **Split DataTree.cs into partial-class files** (Phase 1) as a zero-risk mechanical step to isolate responsibilities before extraction. + +These changes affect **managed C# code only** (Src/Common/Controls/DetailControls and Src/xWorks). No native C++ changes. + +## Capabilities + +### New Capabilities + +- `datatree-model`: UI-framework-agnostic model layer that decides which slices to show, in what order, with what configuration — independent of WinForms or Avalonia. +- `datatree-characterization-tests`: Safety-net test suite covering DataTree's current behavior (XML parsing, show-hidden, slice reuse, navigation, lazy expansion) to reduce refactoring risk. +- `datatree-partial-split`: Mechanical decomposition of DataTree.cs and Slice.cs into partial-class files organized by responsibility, enabling targeted navigation and future extraction. + +### Modified Capabilities + +- `architecture/ui-framework/winforms-patterns`: DataTree transitions from monolithic UserControl to a thin view implementing `IDataTreeView`, delegating logic to `DataTreeModel`. +- `architecture/layers/layer-model`: Introduces a "detail-tree model" sublayer between UI shell and data access, formalizing the separation of "what fields to show" from "how to render them." + +## Impact + +- **Src/Common/Controls/DetailControls/**: DataTree.cs, Slice.cs, SliceFactory.cs gain new collaborators; file count increases but per-file complexity drops sharply. +- **Src/xWorks/RecordEditView.cs**: Primary DataTree consumer — must adapt to create `DataTreeModel` + `DataTree` (view). Public API (`ShowObject`, `Reset`, `CurrentSlice`) remains stable through facade methods. +- **Src/xWorks/DTMenuHandler.cs**: References `DataTree`; will need to accept `IDataTreeView` or continue referencing the concrete WinForms class during transition. +- **Src/LexText/Interlinear/InfoPane.cs**: Contains `StTextDataTree : DataTree` — the only subclass. Must be updated to override model-layer behavior rather than view-layer methods. +- **SliceFactory.cs**: Currently creates WinForms `Slice` objects directly. In the model/view split, it either produces `SliceSpec` descriptors (clean) or remains in the view layer (pragmatic). Decision deferred to design phase. +- **Test infrastructure**: New test fixtures in DetailControlsTests/ for characterization tests. Test XML layouts (Test.fwlayout, TestParts.xml) will be extended. +- **No breaking changes to external consumers** — all changes are internal to the DetailControls and xWorks assemblies. diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md new file mode 100644 index 0000000000..34a2732322 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/coverage-wave2-test-matrix.md @@ -0,0 +1,463 @@ +# Coverage Wave 2 Test Matrix (2026-02-25) + +## Goal + +Increase characterization coverage before refactor by adding deterministic tests in three areas: + +- `DataTree` pure logic and key matching +- `DataTree` command/message handlers +- `DataTree` navigation + UI-adjacent helper logic + +Current baseline from latest coverage artifacts: + +- `DataTree`: 40.59% line / 28.03% branch +- `Slice`: 30.27% line / 19.4% branch +- `ObjSeqHashMap`: 98.39% line / 94.44% branch + +## Rerun Status (2026-02-25, refreshed) + +Reran managed coverage assessment via: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DetailControls"` + +Latest focused class coverage after Wave 2: + +- `DataTree`: **51.91%** line / **38.83%** branch +- `Slice`: **30.88%** line / **19.95%** branch +- `ObjSeqHashMap`: **98.39%** line / **94.44%** branch + +## Rerun Status (2026-02-25, post-Wave 3) + +After Wave 3 test additions and test-file regrouping, coverage was rerun with: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DetailControls"` + +Latest focused class coverage: + +- `DataTree`: **57.96%** line / **43.36%** branch +- `Slice`: **32.55%** line / **21.72%** branch +- `ObjSeqHashMap`: **98.39%** line / **94.44%** branch + +Net effect: post-Wave 3 work significantly improved DataTree and modestly improved Slice (from additional exercised shared paths). + +## Rerun Status (2026-02-25, continued incremental additions) + +After adding another deterministic batch and rerunning: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **57.46%** line / **42.87%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Note: this latest increment kept tests green but did **not** move focused class percentages in the managed assessment output, so the next batch should target methods not currently represented in the focused class method map. + +### Correction: build-backed reruns required + +Subsequent verification found that earlier reruns using `-NoBuild` were not compiling the newest test edits, so coverage appeared artificially flat. The current baseline below is from build-backed runs: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage after additional deterministic tests: + +- `DataTree`: **61.09%** line / **45.34%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the build-backed continuation pass: + +- `DataTree`: **+1.26 line** / **+1.40 branch** (from 59.83 / 43.94) + +## Rerun Status (2026-02-25, latest targeted reassessment) + +After the most recent deterministic Wave 3 additions, managed coverage was rerun with: + +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **62.82%** line / **47.16%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the prior build-backed checkpoint (61.09 / 45.34): + +- `DataTree`: **+1.73 line** / **+1.82 branch** + +## Rerun Status (2026-02-25, post-`RestorePreferences`/`ApplyChildren`/`MakeEditorAt` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.50%** line / **47.57%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous targeted reassessment (62.82 / 47.16): + +- `DataTree`: **+0.68 line** / **+0.41 branch** + +## Rerun Status (2026-02-26, post-`SelectFirstPossibleSlice` deterministic paths) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.70%** line / **47.98%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.50 / 47.57): + +- `DataTree`: **+0.20 line** / **+0.41 branch** + +## Rerun Status (2026-02-26, post-`RefreshList(int,int)` deterministic probes) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **63.96%** line / **48.23%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.70 / 47.98): + +- `DataTree`: **+0.26 line** / **+0.25 branch** + +## Rerun Status (2026-02-26, post-trace + `m_rch_Disposed` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **64.84%** line / **49.22%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (63.96 / 48.23): + +- `DataTree`: **+0.88 line** / **+0.99 branch** + +## Rerun Status (2026-02-26, post-`AddAtomicNode` guard/test-only branches) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.34%** line / **50.04%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (64.84 / 49.22): + +- `DataTree`: **+0.50 line** / **+0.82 branch** + +## Rerun Status (2026-02-26, post-`InsertSliceRange`/`slice_SplitterMoved` guard path) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.80%** line / **50.29%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **62.9%** line / **61.11%** branch + +Incremental gain from the immediately previous checkpoint (65.34 / 50.04): + +- `DataTree`: **+0.46 line** / **+0.25 branch** + +## Rerun Status (2026-02-26, post-`ObjSeqHashMap` reuse/report tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **65.38%** line / **50.04%** branch +- `Slice`: **26.88%** line / **17.9%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Net effect: + +- `ObjSeqHashMap` gaps were substantially reduced (no longer in top-gap list for `GetSliceToReuse`/`Report`). +- `DataTree` remained near the previous high-water mark, with branch coverage still at ~50%. + +## Rerun Status (2026-02-26, post-Wave 4 offscreen + `HandleLayout1` + Slice branch tests) + +Validated with: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **67.51%** line / **52.02%** branch +- `Slice`: **34.5%** line / **22.4%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (65.38 / 50.04 for `DataTree`, 26.88 / 17.9 for `Slice`): + +- `DataTree`: **+2.13 line** / **+1.98 branch** +- `Slice`: **+7.62 line** / **+4.50 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-Slice low-cost method/property batch) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **67.51%** line / **52.02%** branch +- `Slice`: **40.51%** line / **25.68%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (67.51 / 52.02 for `DataTree`, 34.5 / 22.4 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+6.01 line** / **+3.28 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-Slice move paths + DataTree `MakeSliceRealAt`/`ChooseNewOwner` probes) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.68%** branch +- `Slice`: **46.47%** line / **32.1%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (67.51 / 52.02 for `DataTree`, 40.51 / 25.68 for `Slice`): + +- `DataTree`: **+0.76 line** / **+0.66 branch** +- `Slice`: **+5.96 line** / **+6.42 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`EmbeddedSlice`/`ExpandSubItem` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.68%** branch +- `Slice`: **47.63%** line / **33.47%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.68 for `DataTree`, 46.47 / 32.1 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+1.16 line** / **+1.37 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`FocusSliceOrChild` + help-topic fallback + variant backref tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **54.48%** line / **42.76%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.68 for `DataTree`, 47.63 / 33.47 for `Slice`): + +- `DataTree`: **+0.00 line** / **+0.16 branch** +- `Slice`: **+6.85 line** / **+9.29 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`GetCloseSlices` + `GetCanDeleteNow`/`GetCanEditNow` tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **56.04%** line / **44.13%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.84 for `DataTree`, 54.48 / 42.76 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+1.56 line** / **+1.37 branch** +- `ObjSeqHashMap`: **no change** + +## Rerun Status (2026-02-26, post-`GetSeqContext`/`GetAtomicContext` + `GetCanSplitNow` null-path tests) + +Validated with: + +- `./test.ps1 -TestFilter "FullyQualifiedName~SliceTests|FullyQualifiedName~DataTreeTests"` +- `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -NoBuild -TestFilter "FullyQualifiedName~DataTreeTests|FullyQualifiedName~SliceTests"` + +Latest focused class coverage from `coverage-gap-assessment.md`: + +- `DataTree`: **68.27%** line / **52.84%** branch +- `Slice`: **60.27%** line / **46.45%** branch +- `ObjSeqHashMap`: **90.32%** line / **83.33%** branch + +Incremental gain from the immediately previous checkpoint (68.27 / 52.84 for `DataTree`, 56.04 / 44.13 for `Slice`): + +- `DataTree`: **no change** +- `Slice`: **+4.23 line** / **+2.32 branch** +- `ObjSeqHashMap`: **no change** + +Wave 2 desired target: + +- `DataTree` line coverage toward ~46-50% +- `DataTree` branch coverage toward ~34-38% + +## Harness Types + +- **H1 — Existing DataTree fixture**: `DataTreeTests` with in-memory backend, `Mediator`, `PropertyTable`, `Form` host. +- **H2 — Reflection helper harness**: invoke private methods (`BindingFlags.NonPublic`) for pure logic methods. +- **H3 — Command XML harness**: create `Command` from inline XML + `UIItemDisplayProperties` for `OnDisplay*` handlers. +- **H4 — Navigation host harness**: use existing `Form` host and `ShowObject` layouts (`NavigationTest`, `CfOnly`, `CfAndBib`) to drive slice focus/visibility. +- **H5 — Bitmap paint harness (planned, not in initial implementation pass)**: `Bitmap` + `Graphics.FromImage` + `PaintEventArgs`. + +## Planned Tests (Detailed) + +## Package A — Pure Logic and Key Matching (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `EquivalentKeys_LengthMismatch_ReturnsFalse` | `EquivalentKeys` | +2 lines, branch false path | H2 | +| `EquivalentKeys_XmlNodesWithSameNameInnerAndAttributes_ReturnsTrue` | `EquivalentKeys` | +7 lines, xml/attr loop true path | H2 | +| `EquivalentKeys_XmlNodesWithAttributeMismatch_ReturnsFalse` | `EquivalentKeys` | +5 lines, xml attr mismatch branch | H2 | +| `EquivalentKeys_IntComparisonHonorsCheckFlag` | `EquivalentKeys` | +4 lines, int/fCheckInts paths | H2 | +| `EquivalentKeys_DifferentNonComparableTypes_ReturnsFalse` | `EquivalentKeys` | +2 lines, terminal false branch | H2 | +| `FindMatchingSlices_FindsSliceForObjectAndKey` | `FindMatchingSlices`, `EquivalentKeys` | +10 lines, match path | H1+H2 | +| `FindMatchingSlices_NoMatch_ReturnsNulls` | `FindMatchingSlices` | +6 lines, no-match loop path | H1+H2 | +| `IsChildSlice_MatchingPrefix_ReturnsTrue` | `IsChildSlice` | +6 lines, positive path | H2 | +| `IsChildSlice_ShortOrNullSecondKey_ReturnsFalse` | `IsChildSlice` | +4 lines, null/len guard | H2 | +| `IsChildSlice_MismatchedPrefix_ReturnsFalse` | `IsChildSlice` | +4 lines, mismatch loop branch | H2 | +| `GetClassId_DelegatesToMetadataCache` | `GetClassId` | +2 lines | H1 | + +**Package A expected gain (DataTree):** ~45-55 lines + +## Package B — Command/Message Handlers (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `GetMessageTargets_NotVisible_ReturnsEmpty` | `GetMessageTargets` | +4 lines, visibility false path | H1 | +| `GetMessageTargets_VisibleWithoutCurrentSlice_ReturnsTreeOnly` | `GetMessageTargets` | +5 lines, visible/default path | H1 | +| `GetMessageTargets_VisibleWithCurrentSlice_ReturnsSliceAndTree` | `GetMessageTargets` | +5 lines, current-slice path | H1 | +| `OnDisplayShowHiddenFields_AllowedAndSet_ShowsChecked` | `OnDisplayShowHiddenFields` | +8 lines, allowed/checked path | H1+H3 | +| `OnDisplayShowHiddenFields_NotAllowed_Disables` | `OnDisplayShowHiddenFields` | +6 lines, disallowed path | H1+H3 | +| `OnDelayedRefreshList_ArgumentTogglesDoNotRefresh` | `OnDelayedRefreshList` | +3 lines | H1 | +| `OnDisplayInsertItemViaBackrefVector_MatchingClass_Enabled` | `OnDisplayInsertItemViaBackrefVector` | +8 lines, enabled path | H1+H3 | +| `OnDisplayInsertItemViaBackrefVector_WrongClass_Disabled` | `OnDisplayInsertItemViaBackrefVector` | +7 lines, disabled path | H1+H3 | +| `OnDisplayDemoteItemInVector_NonRnRoot_Disables` | `OnDisplayDemoteItemInVector` | +7 lines, guard branch | H1+H3 | +| `OnDisplayJumpToTool_ValidCommand_Enables` | `OnDisplayJumpToTool` | +8 lines, happy path | H1+H3 | + +**Package B expected gain (DataTree):** ~60-75 lines + +## Package C — Navigation and Utility Paths (`DataTreeTests.cs`) + +| Planned test | Target method(s) | Desired coverage impact | Harness | +|---|---|---:|---| +| `NextFieldAtIndent_FindsNextAtSameIndent` | `NextFieldAtIndent` | +6 lines | H1+H4 | +| `NextFieldAtIndent_StopsWhenIndentDecreases` | `NextFieldAtIndent` | +4 lines | H1+H4 | +| `PrevFieldAtIndent_FindsPreviousAtSameIndent` | `PrevFieldAtIndent` | +6 lines | H1+H4 | +| `PrevFieldAtIndent_StopsWhenIndentDecreases` | `PrevFieldAtIndent` | +4 lines | H1+H4 | +| `IndexOfSliceAtY_ReturnsExpectedIndexAndMinusOneAfterLast` | `IndexOfSliceAtY`, `HeightOfSliceOrNullAt` | +10 lines | H1+H4 | +| `GotoNextSliceAfterIndex_AtEnd_ReturnsFalse` | `GotoNextSliceAfterIndex` | +6 lines, fail path | H1+H4 | +| `GotoPreviousSliceBeforeIndex_AtStart_ReturnsFalse` | `GotoPreviousSliceBeforeIndex` | +6 lines, fail path | H1+H4 | +| `MakeSliceVisible_TargetSliceAndPriorSlicesBecomeVisible` | `MakeSliceVisible` | +10 lines | H1+H4 | +| `GotoFirstSlice_SetsCurrentSlice_WhenFocusable` | `GotoFirstSlice`, `GotoNextSliceAfterIndex` | +8 lines | H1+H4 | + +**Package C expected gain (DataTree):** ~50-65 lines + +## Planned but deferred to next pass (higher harness complexity) + +| Planned test area | Method(s) | Why deferred | Harness | +|---|---|---|---| +| Paint state machine tests | `OnPaint`, `HandlePaintLinesBetweenSlices` | Need controlled paint/layout surface and robustness checks | H5 | +| Context menu popup behavior | `OnShowContextMenu`, `GetSliceContextMenu` | Requires reliable context menu handler/event plumbing in tests | H1+H3 | +| Focus idle-queue behavior | `OnFocusFirstPossibleSlice`, `DoPostponedFocusSlice`, `FocusFirstPossibleSlice` | Message-pump sensitivity in CI | H4 + pump surrogate | +| Deep slice expansion matrix | `Slice.GenerateChildren`, `CreateIndentedNodes` | Needs richer layout fixture and expansion-state matrix | New Slice lifecycle harness | + +## Subagent Implementation Plan + +- **Subagent A (Pure Logic):** implement Package A tests + minimal reflection helpers. +- **Subagent B (Command Handlers):** implement Package B tests + command helper method(s). +- **Subagent C (Navigation):** implement Package C tests using existing layouts and form host. + +All code changes are expected in: + +- `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs` + +Validation after all subagents complete: + +- `./test.ps1 -NoBuild -TestFilter "FullyQualifiedName~DetailControls"` +- Coverage rerun via `./Build/Agent/Run-ManagedCoverageAssessment.ps1 -TestFilter "FullyQualifiedName~DetailControls"` + +## Wave 3 Follow-on Work (current) + +To keep test growth manageable, DataTree tests are now being split into logical partial-class files: + +- `DataTreeTests.cs` (core fixture/setup + existing suites) +- `DataTreeTests.Wave3.CommandsAndProps.cs` (command handlers + low-cost property coverage) +- `DataTreeTests.Wave3.Navigation.cs` (additional navigation edge/path coverage) + +Wave 3 focus is on additional deterministic methods still listed as 0% in the gap report, especially: + +- `OnDisplayJumpToLexiconEditFilterAnthroItems` +- `OnDisplayJumpToNotebookEditFilterAnthroItems` +- `OnJumpToTool` +- `OnReadyToSetCurrentSlice` +- `OnFocusFirstPossibleSlice` +- `get_LastSlice`, `get_LabelWidth`, `get_Priority`, `get_ShouldNotCall`, `get_SliceControlContainer` + +Wave 3 implementation status: + +- ✅ Added grouped files: + - `DataTreeTests.Wave3.CommandsAndProps.cs` + - `DataTreeTests.Wave3.Navigation.cs` +- ✅ Added deterministic tests for: + - Jump-to-tool display/action handlers (`OnDisplayJumpToLexicon...`, `OnDisplayJumpToNotebook...`, `OnJumpToTool`) + - Message-target path where tree is hidden but `CurrentSlice` exists + - Property/unit gaps (`Priority`, `ShouldNotCall`, `SliceControlContainer`, `LabelWidth`, `LastSlice`, `SliceSplitPositionBase`, `SmallImages`, `StyleSheet`, `PersistenceProvder`, `ConstructingSlices`, `HasSubPossibilitiesSlice`) + - Additional navigation edge paths (`GotoFirstSlice` with no slices, `GotoNextSlice` with null/last current, `IndexOfSliceAtY` empty tree, `GotoPreviousSliceBeforeIndex` empty tree) + - Context menu plumbing (`GetSliceContextMenu`, `SetContextMenuHandler`, non-popup `OnShowContextMenu`) + - Additional low-risk action/utility probes (`RefreshDisplay`, `NotebookRecordRefersToThisText`, `SetCurrentObjectFlids/ClearCurrentObjectFlids`, `PostponePropChanged`, `PrepareToGoAway`, `OnInsertItemViaBackrefVector` wrong-class guard, `OnDemoteItemInVector` null-root guard) + - Additional deterministic gap reducers (`PropChanged` monitored/unmonitored with refresh suppression, `ResetRecordListUpdater` no-owner path, `OnInsertItemViaBackrefVector` missing-field guard, `OnDemoteItemInVector` non-notebook-root guard) diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md new file mode 100644 index 0000000000..f59a98ba50 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/spec.md @@ -0,0 +1,159 @@ +# Changes From Test Before Refactor + +## Purpose + +During Phase 0 (characterization testing), several code-level issues were discovered in `DataTree.cs`, `Slice.cs`, `ObjSeqHashMap.cs`, and the test infrastructure that should be addressed **before** the Phase 1 partial-class split begins. Fixing these first reduces noise during the structural refactor and prevents propagating known defects into the new architecture. + +## Issues Discovered + +### 1. `m_monitoredProps` Never Clears (DataTree.cs) + +**Location**: `DataTree.MonitorProp()` (~line 560) and `DataTree.RefreshList()` (~line 1413) + +**Problem**: `m_monitoredProps` is a `HashSet>` that accumulates entries across successive `ShowObject` / `RefreshList` calls. It is never cleared, even when the root object changes. Over a long editing session, this can cause unnecessary refresh triggers from stale monitored properties that no longer correspond to visible slices. + +**Suggested Fix**: Clear `m_monitoredProps` at the start of `CreateSlices()` or `RefreshList()` when building a new slice tree. The characterization test `MonitoredProps_AccumulatesAcrossRefresh` (when written) should verify this new behavior. + +**Risk**: Low — monitored props are used only for deciding whether `PropChanged` triggers a full refresh. Clearing when rebuilding the slice tree aligns intent with implementation. + +### 2. `GetFlidIfPossible` Static Cache Collision Risk (DataTree.cs) + +**Location**: `DataTree.GetFlidIfPossible()` (~line 3050) + +**Problem**: Uses a `static Dictionary` cache keyed by field name. If two different classes have fields with the same name but different flids, the first call wins and subsequent calls return the wrong flid. This is a latent bug that could produce incorrect slice generation. + +**Suggested Fix**: Change the cache key to include the class ID: `$"{classId}:{fieldName}"`. Add a characterization test that verifies correct flid resolution for same-named fields on different classes. + +**Risk**: Very low — adding class ID to the key is strictly more correct. + +### 3. `ObjSeqHashMap.Values` May Double-Count Slices (ObjSeqHashMap.cs) + +**Location**: `ObjSeqHashMap.Values` property + +**Problem**: The `Values` property iterates all lists inside `m_table` and all values in `m_slicesToReuse`, but a slice can exist in both collections simultaneously (added to `m_table` during `Setup`, then also moved to `m_slicesToReuse` during `GetSliceToReuse`). This could return duplicate references. + +**Suggested Fix**: Return a deduplicated set (e.g., `HashSet`) or document the intentional duplication. Add a test that verifies whether Values contains duplicates after typical Setup/GetSliceToReuse usage. + +**Risk**: Low — `Values` is used only during refresh cleanup to dispose leftover slices. Disposing twice is handled, but the double iteration is wasteful. + +### 4. `SelectAt(99999)` Magic Number in Navigation (DataTree.cs) + +**Location**: `DataTree.FocusFirstPossibleSlice()` and related navigation methods + +**Problem**: Uses the literal `99999` as a "select all text" constant when calling `SelectAt()`. This is fragile and undocumented. + +**Suggested Fix**: Extract to a named constant: `private const int SelectAllText = 99999;` (or ideally use `int.MaxValue` if the downstream API supports it). This is a mechanical cleanup with no behavioral change. + +**Risk**: None — rename only. + +### 5. `PropChanged` Uses `BeginInvoke` Without Message Pump (DataTree.cs) + +**Location**: `DataTree.PropChanged()` (~line 578) + +**Problem**: `PropChanged` calls `BeginInvoke()` to defer `PostponedPropChanged()`, which requires a Windows message pump. In NUnit tests (which run without a message pump), `BeginInvoke` callbacks never execute, making it impossible to test the full `PropChanged → RefreshList` chain in unit tests. + +**Impact on Testing**: This is a fundamental testability barrier. The characterization test for `PropChanged` can only verify that `m_fOutOfDate` is set (via `DoNotRefresh`), but cannot verify that `RefreshList` is actually called. + +**Suggested Fix (Pre-Refactor)**: No code change needed yet — this is a design constraint to document. During Phase 2 (Extract Collaborators), the model layer should use a synchronous notification pattern (e.g., `Action` callback or event) instead of `BeginInvoke`, enabling full unit test coverage. + +**Risk**: N/A — documentation only for now. + +### 6. UOW Nesting in Test Harness + +**Location**: `DataTreeTests.cs` test setup and tests that modify data + +**Problem**: The test base class `MemoryOnlyBackendProviderRestoredForEachTestTestBase` already provides an active UOW (unit of work). Tests that call `NonUndoableUnitOfWorkHelper.Do()` fail with `InvalidOperationException: Nested tasks are not supported`. Not all tests need to modify data, but those that do (e.g., deleting an object for RefreshList testing) must work within the existing UOW. + +**Suggested Fix**: Use `Cache.ActionHandlerAccessor` directly (objects can be deleted/modified within the existing UOW without wrapping in `NonUndoableUnitOfWorkHelper`). Document this pattern for future test authors. + +**Risk**: None — the fix is already applied in the current test code. + +### 7. `ManySenses` Layout Resolution in Test Harness + +**Location**: `Test.fwlayout` / `TestParts.xml` and `DataTreeTests.ManySenses_LargeSequence_CreatesSomeSlices` + +**Problem**: The `ManySenses` layout uses `` with a `seq` element that expands to child items. In the test harness, all 25 senses produce slices, but none are "real" (`IsRealSlice` returns false for all). This suggests that when the child count exceeds `kInstantSliceMax` (20), the DataTree wraps entire sequences in DummyObjectSlice, not individual items. + +**Impact**: The characterization test documents this behavior but cannot assert the mix of real vs dummy slices. This limitation should be understood before refactoring the DummyObjectSlice pathway. + +**Suggested Fix**: No code change needed — add a detailed comment in the test documenting the observed behavior and the threshold mechanism. + +**Risk**: None — documentation only. + +### 8. Missing Test Coverage for Key Behaviors + +The following behaviors from the test plans (`test-plan-datatree.md`, `test-plan-slice.md`) are partially or fully uncovered in the current characterization test batch. These should be completed before Phase 1 begins: + +| Area | Gap | Priority | +|------|-----|----------| +| Slice.Expand/Collapse | Persistence of expansion state via PropertyTable | Medium | +| Slice.GenerateChildren | NothingResult, PossibleResult, PersistentExpansion | Medium | +| DataTree.FieldAt | DummyObjectSlice expansion on access | High | +| DataTree.GetMessageTargets | Visible vs hidden vs no current slice | Low | +| DataTree.PostponePropChanged | Deferred refresh chain | Medium | +| SliceFactory.Create | Editor dispatch: multistring, string, unknown, null | Low | +| Slice.GetCanDeleteNow | Required/optional field logic | Medium | +| Slice.GetCanMergeNow | Same-class sibling check | Medium | + +## Recommended Order of Changes + +1. **Items 4, 6** (zero-risk renames and pattern documentation) — immediate +2. **Item 8** (complete missing test coverage) — before Phase 1 +3. **Items 1, 2, 3** (functional fixes) — during or just before Phase 1, with characterization tests verifying both old and new behavior +4. **Items 5, 7** (design constraints) — addressed during Phase 2 architecture changes + +## Relationship to Other Phases + +- **Phase 0** (characterization tests): This spec captures discoveries made during Phase 0. +- **Phase 1** (partial-class split): Items 1–4 should be resolved before splitting, so the split starts from a cleaner baseline. +- **Phase 2** (extract collaborators): Item 5 directly informs the notification pattern for `DataTreeModel`. +- **Phase 3** (model/view separation): Item 7 informs testing strategy for `SliceSpec` generation. + +## Coverage Findings (2026-02-25) + +Coverage was re-run locally using `Build/Agent/Run-TestCoverage.ps1` and assessed via +`.github/skills/managed-test-coverage-assessment/scripts/Assess-CoverageGaps.ps1`. + +### Focused Class Coverage Snapshot + +| Class | Line % | Branch % | +|------|-------:|---------:| +| `DataTree` | 40.59 | 28.03 | +| `Slice` | 30.27 | 19.40 | +| `ObjSeqHashMap` | 98.39 | 94.44 | + +### Gap Classification Summary (Top Focused Methods) + +| Suggested Resolution | Count | +|----------------------|------:| +| `add-tests-or-evaluate-relevance` | 109 | +| `add-targeted-tests` | 43 | +| `add-unit-tests` | 41 | +| `simplify-architecture-or-add-ui-harness` | 23 | +| `add-functional-tests` | 16 | +| `dead-code-or-debug-path-review` | 3 | + +### Implemented Coverage-Reduction Tests (This Batch) + +The following tests were implemented to reduce deterministic unit-test gaps: + +- `DataTreeTests.DoNotRefresh_GetterReflectsSetter` +- `DataTreeTests.GetFlidIfPossible_ValidField_ReturnsFlid` +- `DataTreeTests.GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` +- `DataTreeTests.GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` +- `SliceTests.IsSequenceNode_TrueForOwningSequence` +- `SliceTests.IsCollectionNode_TrueForNonOwningSequenceField` +- `ObjSeqHashMapTests.Report_DoesNotThrow_WhenMapContainsEntries` + +### Artifacts + +- `Output/Debug/Coverage/coverage-summary.md` +- `Output/Debug/Coverage/coverage-summary.json` +- `Output/Debug/Coverage/coverage-gap-assessment.md` +- `Output/Debug/Coverage/coverage-gap-assessment.json` + +### Current Prioritization After This Run + +1. Continue with deterministic `add-unit-tests` in `DataTree` and `Slice` (non-UI paths). +2. Defer `simplify-architecture-or-add-ui-harness` methods unless extracted into pure collaborators. +3. Review `dead-code-or-debug-path-review` candidates with maintainers before any removal. diff --git a/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md new file mode 100644 index 0000000000..ebd2c7eedf --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/changes-from-test-before-refactor/tests to fix coverage gaps.md @@ -0,0 +1,70 @@ +# Tests To Fix Coverage Gaps + +## Scope + +This plan targets the classes affected by the current refactor-prep work and test additions: + +- `DataTree.cs` +- `Slice.cs` +- `ObjSeqHashMap.cs` + +Coverage input source: `Output/Debug/Coverage/detailcontrols-method-gaps.txt` generated by `Build/Agent/Run-TestCoverage.ps1`. + +## Functionality Gaps + +1. **DataTree field metadata lookup** + - **Gap:** `DataTree.GetFlidIfPossible` had no direct coverage. + - **Tests:** + - `GetFlidIfPossible_ValidField_ReturnsFlid` + - **Intent:** confirm valid metadata lookup path returns non-zero flid. + +2. **Slice sequence/collection classification** + - **Gap:** `Slice.IsSequenceNode` and `Slice.IsCollectionNode` had no direct coverage. + - **Tests:** + - `IsSequenceNode_TrueForOwningSequence` + - `IsCollectionNode_TrueForNonOwningSequenceField` + - **Intent:** lock current behavior for XML `` interpretation. + +## Edge-Case Gaps + +1. **Invalid field cache behavior in DataTree** + - **Gap:** repeated invalid lookups and cache behavior untested. + - **Tests:** + - `GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` + - `GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` + - **Intent:** verify failed lookups return 0 and are cached once. + +2. **DoNotRefresh getter symmetry** + - **Gap:** setter path covered, getter path still weak. + - **Test:** + - `DoNotRefresh_GetterReflectsSetter` + - **Intent:** lock down state visibility for deferred refresh behavior. + +## Error-Handling / Robustness Gaps + +1. **ObjSeqHashMap diagnostic path** + - **Gap:** `ObjSeqHashMap.Report()` uncovered. + - **Test:** + - `Report_DoesNotThrow_WhenMapContainsEntries` + - **Intent:** ensure debug/report path is safe under non-empty state. + +## Additional Planned (Not Implemented In This Batch) + +These remain high-value but need larger fixture setup or message-pump infrastructure: + +- `DataTree.PropChanged`/`PostponePropChanged` full async refresh chain +- `Slice.GetCanDeleteNow` and `Slice.GetCanMergeNow` branch matrix +- `DataTree.GetMessageTargets` visibility matrix +- `Slice.GenerateHelpTopicId` fallback chain + +## Implementation Status + +Implemented in this batch: + +- ✅ `GetFlidIfPossible_ValidField_ReturnsFlid` +- ✅ `GetFlidIfPossible_InvalidField_ReturnsZero_AndCachesInvalidKey` +- ✅ `GetFlidIfPossible_InvalidField_SecondCallDoesNotGrowCache` +- ✅ `DoNotRefresh_GetterReflectsSetter` +- ✅ `IsSequenceNode_TrueForOwningSequence` +- ✅ `IsCollectionNode_TrueForNonOwningSequenceField` +- ✅ `Report_DoesNotThrow_WhenMapContainsEntries` diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md new file mode 100644 index 0000000000..84c9c17d3e --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/spec.md @@ -0,0 +1,73 @@ +## ADDED Requirements + +### Requirement: Characterization tests for XML-to-slice mapping + +The test suite SHALL verify that `ShowObject` with a given layout name and root object produces the correct ordered list of slices (by label, indent level, and count). + +#### Scenario: Simple layout produces expected slices +- **WHEN** `ShowObject` is called with a layout containing two `` references (CitationForm, Bibliography) and the object has data in both fields +- **THEN** exactly two slices are created, with labels matching the part labels in order + +#### Scenario: Nested/expanded layout produces header plus children +- **WHEN** `ShowObject` is called with a layout using `` and a header node +- **THEN** the first slice is a header node and subsequent slices have the correct indent level + +### Requirement: Characterization tests for show-hidden-fields toggle + +The test suite SHALL verify that the show-hidden-fields mechanism correctly shows or hides `ifData`-empty and `visibility="never"` slices based on the tool-specific property key. + +#### Scenario: ifData slices hidden when show-hidden is off +- **WHEN** `ShowObject` is called with `ShowHiddenFields` off and a field has no data +- **THEN** the `ifData` slice for that field is excluded from the slice list + +#### Scenario: ifData slices revealed when show-hidden is on +- **WHEN** `ShowObject` is called with `ShowHiddenFields` on for the current tool +- **THEN** the `ifData` slice for an empty field is included in the slice list + +#### Scenario: visibility=never slices revealed when show-hidden is on +- **WHEN** `ShowObject` is called with `ShowHiddenFields` on and a part has `visibility="never"` +- **THEN** the slice for that part is included in the slice list + +#### Scenario: SliceFilter bypassed when show-hidden is on +- **WHEN** a `SliceFilter` is configured to exclude a slice by ID and `ShowHiddenFields` is on +- **THEN** the filtered slice is included in the slice list despite the filter + +### Requirement: Characterization tests for slice reuse during refresh + +The test suite SHALL verify that `RefreshList` reuses existing slice instances when the object and layout have not changed. + +#### Scenario: Same object refresh reuses slices +- **WHEN** `ShowObject` is called, slice references are captured, and `RefreshList(false)` is called +- **THEN** at least the first slice instance in the new list is the same object reference as before + +### Requirement: Characterization tests for PropChanged notification + +The test suite SHALL verify that modifying a monitored property triggers a refresh of the slice list. + +#### Scenario: Monitored property change triggers refresh +- **WHEN** a property registered via `MonitorProp` is changed in the cache +- **THEN** `RefreshListNeeded` becomes true or the slice list is rebuilt + +### Requirement: Characterization tests for focus navigation + +The test suite SHALL verify that `GotoNextSlice` and `GotoPreviousSliceBeforeIndex` navigate correctly through the slice list. + +#### Scenario: GotoNextSlice advances to next focusable slice +- **WHEN** `GotoNextSlice` is called with the first slice focused +- **THEN** `CurrentSlice` moves to the next non-header, focusable slice + +#### Scenario: GotoPreviousSliceBeforeIndex moves backward +- **WHEN** `GotoPreviousSliceBeforeIndex` is called with the last slice's index +- **THEN** `CurrentSlice` moves to the previous focusable slice + +### Requirement: Characterization tests for DummyObjectSlice expansion + +The test suite SHALL verify that sequences with more than `kInstantSliceMax` (20) items use `DummyObjectSlice` placeholders that expand on demand. + +#### Scenario: Large sequence uses dummy slices +- **WHEN** `ShowObject` is called with a sequence property containing 25 items +- **THEN** some slices in the list are `DummyObjectSlice` instances (not real slices) + +#### Scenario: FieldAt expands dummy to real slice +- **WHEN** `FieldAt(i)` is called on an index occupied by a `DummyObjectSlice` +- **THEN** the slice at that index becomes a real slice (`IsRealSlice == true`) diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md new file mode 100644 index 0000000000..6b0f285cf5 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-datatree.md @@ -0,0 +1,483 @@ +# DataTree Characterization Test Plan + +## Purpose + +This document details every characterization test needed for `DataTree.cs` +before the model/view separation. Tests are organized by subdomain +(matching the partial-class file split). Each entry documents: + +- What behavior to lock down +- Whether a test already exists +- The test name and fixture pattern +- Edge cases to cover + +Tests are primarily in `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTests.cs` +and its partial companions (`DataTreeTests.Wave3.*`, `DataTreeTests.Wave4.OffscreenUI.cs`), +unless otherwise noted. + +--- + +## Subdomain 1: XML Layout Parsing (`DataTree.LayoutParsing.cs`) + +These tests cover lines ~1743–2960 of DataTree.cs — the XML-to-slice +pipeline from `CreateSlicesFor` through `AddSimpleNode`. + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `OneStringAttr` | Single `multistring` slice from layout | +| 2 | `TwoStringAttr` | Two slices, correct ordering | +| 3 | `LabelAbbreviations` | 3 abbreviation modes | +| 4 | `IfDataEmpty` | `visibility="ifdata"` hides empty fields | +| 5 | `NestedExpandedPart` | Header + nested children, indent=0 | +| 6 | `RemoveDuplicateCustomFields` | `customFields="here"` dedup | +| 7 | `BadCustomFieldPlaceHoldersAreCorrected` | Missing `ref` attr fixup | +| 8 | `OwnedObjects` | `` expansion across senses + etymology | + +### New Tests Needed + +#### 1.1 `CreateSlicesFor_NullObject_ReturnsEmptySliceList` +- **What:** Call `ShowObject(null, ...)` — verify no slices created +- **Edge case:** Null object after disposal guard + +#### 1.2 `GetTemplateForObjLayout_ClassHierarchyWalk` +- **What:** Create an object whose exact class has no layout, but a + base class does. Verify the base-class layout is used. +- **Fixture:** Add a layout for `CmObject` (generic base) but not for + the specific subclass. Verify slices match the base layout. +- **Edge case:** Class hierarchy walk terminates at `CmObject` (id=0) + +#### 1.3 `GetTemplateForObjLayout_CmCustomItemUsesWsLayout` +- **What:** For `CmCustomItem`, verify the layout selection considers + the containing list's WS (`Names.BestAnalysisAlternative`) to pick + between analysis/vernacular name layouts. +- **Fixture:** Create a `CmCustomItem` in a custom list, verify layout + name resolution. This requires a custom list + custom item in the + test cache. + +#### 1.4 `ApplyLayout_DuplicateCustomFieldPlaceholders` +- **What:** Already partially covered by `RemoveDuplicateCustomFields`. + Extend to verify that custom field parts are actually injected at the + placeholder position and that only the first placeholder survives. +- **Assertions:** Slice count matches expected; custom field slice + appears at the correct index. + +#### 1.5 `ProcessSubpartNode_SequenceWithMoreThanThresholdItems` +- **What:** Create an entry with >20 senses (`kInstantSliceMax`). + Verify that `AddSeqNode` creates some `DummyObjectSlice` instances. +- **Fixture:** New layout `ManySenses` referencing ``. + Populate 25 senses. +- **Assertions:** + - Total slice count == 25 + - At least one slice has `IsRealSlice == false` + - Do **not** assume the first `kInstantSliceMax` are real; current behavior may produce all dummy placeholders for large sequences. + +#### 1.6 `ProcessSubpartNode_ThresholdOverride_ExpandedCaller` +- **What:** When the caller node has `expansion="expanded"`, the + current implementation does not reliably force eager instantiation for all items. +- **Fixture:** Layout with ``. + 25 senses. +- **Assertions:** Document observed behavior (which may still include dummy placeholders) and avoid asserting all-real slices unless code changes to guarantee that contract. + +#### 1.7 `ProcessSubpartNode_ThresholdOverride_PersistentExpansion` +- **What:** When the user previously expanded a node (stored in + `PropertyTable`), the threshold is overridden. +- **Setup:** Set the expansion key in `PropertyTable` before + `ShowObject`. + +#### 1.8 `ProcessSubpartNode_ThresholdOverride_EmptySequence` +- **What:** An empty `` with no items produces zero child slices + (no dummy, no ghost unless configured). +- **Assertions:** Slice count = 0 for the sequence region. + +#### 1.9 `AddSimpleNode_IfData_MultiString_EmptyAnalysis` +- **What:** `visibility="ifdata"` on a `MultiString` field with an + empty analysis writing system. Verify the slice is hidden. +- **Fixture:** Layout with `editor="multistring" ws="analysis" + visibility="ifdata"`; object has empty analysis WS. + +#### 1.10 `AddSimpleNode_IfData_MultiString_NonEmptyVernacular` +- **What:** Same field with data in vernacular WS, but layout + requests `ws="vernacular"`. Verify the slice is shown. + +#### 1.11 `AddSimpleNode_IfData_StText_EmptyParagraph` +- **What:** `StText` field with a single empty paragraph. Verify the + slice is hidden when `visibility="ifdata"`. +- **Edge case:** The code specifically checks for `StText` with one + empty paragraph and treats it as empty data. + +#### 1.12 `AddSimpleNode_IfData_Summary_SuppressesNode` +- **What:** When a `summary` node attribute is present and the + referenced data is empty, the summary is suppressed. + +#### 1.13 `AddSimpleNode_CustomEditor_ReflectionLookup` +- **What:** When `editor` attribute names a class (e.g., + `SIL.FieldWorks.XWorks.MorphologyEditor.PhonologicalRuleFormulaSlice`), + the factory resolves it via reflection. Verify this path works for + a known editor class. +- **Note:** This is more of an integration test; may belong in + `SliceFactoryTests`. + +#### 1.14 `ProcessSubpartNode_IfNotCondition` +- **What:** `` node checks `XmlVc.ConditionPasses` and includes + children only when the condition is _not_ met. +- **Fixture:** Layout with `...`. Test with objects that do and don't + match. + +#### 1.15 `ProcessSubpartNode_ChoiceWhereOtherwise` +- **What:** `` node evaluates `` clauses in order; + first match wins; `` is the fallback. +- **Fixture:** Layout with `...... + ...`. + +#### 1.16 `AddAtomicNode_GhostSliceCreation` +- **What:** When an atomic property is null and `ghost="fieldName"` is + specified, a ghost slice is created to allow inline object creation. +- **Assertions:** Ghost slice has `IsGhostSlice == true`. + +#### 1.17 `InterpretLabelAttribute_DollarOwnerPrefix` +- **What:** When `label` attribute starts with `$owner.`, the label is + resolved by walking up the ownership chain. +- **Fixture:** Layout with `label="$owner.Form"` on a sense-level + slice. Verify the label comes from the owning entry. + +#### 1.18 `SetNodeWeight_ValidWeights` +- **What:** `weight` XML attribute is parsed and applied to slice. + Test all valid weight values: `field`, `heavy`, `light`. + +#### 1.19 `GetFlidIfPossible_CachingBehavior` +- **What:** `GetFlidIfPossible` uses a static `Dictionary` cache + keyed by `"className-fieldName"`. Verify that repeated calls return + the cached value. Note potential collision risk if two classes share + the same field name with different flids — document this risk even + if we don't fix it. + +--- + +## Subdomain 2: ShowObject & Show-Hidden Fields (`DataTree.Persistence.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `ShowObject_ShowHiddenEnabledForCurrentTool_...` | Tool-keyed show-hidden | +| 2 | `ShowObject_NoCurrentContentControl_...` | Fallback to lexiconEdit | +| 3 | `ShowObject_NonLexiconCurrentContentControl_...` | Override for LexEntry | +| 4 | `ShowObject_ShowHiddenForDifferentTool_...` | Tool isolation | +| 5 | `ShowObject_ShowHiddenEnabled_RevealsNeverVisibility...` | `visibility="never"` reveal | +| 6 | `OnPropertyChanged_ShowHiddenFields_Toggles...` | Toggle via mediator message | +| 7 | `OnDisplayShowHiddenFields_CheckedState_...` | Menu checked state | +| 8 | `ShowObject_ShowHiddenEnabled_BypassesSliceFilter` | Filter bypass | + +### New Tests Needed + +#### 2.1 `ShowObject_SameRootAndDescendant_DoesRefreshList` +- **What:** Calling `ShowObject` with the same root, same layout, same + descendant triggers `RefreshList(false)` not `CreateSlices(true)`. +- **Assertions:** Slice instances survive (reference equality). + +#### 2.2 `ShowObject_DifferentRoot_RecreatesAllSlices` +- **What:** Changing the root object disposes old slices and creates + new ones. +- **Assertions:** Old slice references are disposed; new slices exist. + +#### 2.3 `ShowObject_SameRootDifferentDescendant_SetsCurrentSliceNew` +- **What:** Same root but different descendant → only + `SetCurrentSliceNewFromObject` is called. +- **Assertions:** After idle processing, `CurrentSlice.Object` matches + the descendant. + +#### 2.4 `ShowObject_NoOp_WhenAllParametersUnchanged` +- **What:** Calling `ShowObject` with identical parameters is a no-op. +- **Assertions:** Slice count and references unchanged. + +#### 2.5 `RefreshList_DoNotRefresh_DefersRefresh` +- **What:** Set `DoNotRefresh = true`, call `RefreshList`. Verify slices + are NOT rebuilt. Then set `DoNotRefresh = false` — verify the deferred + refresh fires. +- **Assertions:** Slice count only changes after `DoNotRefresh = false`. + +#### 2.6 `RefreshList_CurrentSliceSurvivesRefresh` +- **What:** If the current slice's identity (type + config + caller + + label + object GUID) matches a slice after refresh, it remains + current. +- **Assertions:** `CurrentSlice` after refresh has same `Key` as before. + +#### 2.7 `RefreshList_InvalidRoot_CallsReset` +- **What:** If `m_root` is invalid (e.g., deleted), `RefreshList` should + call `Reset()` and produce zero slices. + +#### 2.8 `GetShowHiddenFieldsToolName_LexEntry_FallbackToLexiconEdit` +- **What:** Already partially covered. Add explicit unit test that + exercises the 4 branches: + 1. `ILexEntry` + no `currentContentControl` → `"lexiconEdit"` + 2. `ILexEntry` + `currentContentControl = "notebookEdit"` → + `"lexiconEdit"` (override) + 3. `ILexEntry` + `currentContentControl = "lexiconEdit-variant"` → + `"lexiconEdit-variant"` (starts with `"lexiconEdit"`) + 4. Non-`ILexEntry` + `currentContentControl = "notebookEdit"` → + `"notebookEdit"` (pass-through) + +#### 2.9 `SetCurrentSlicePropertyNames_ConstructsCorrectKeys` +- **What:** Verify the property-table keys follow the pattern + `{area}${tool}$CurrentSlicePartName` and + `{area}${tool}$CurrentSliceObjectGuid`. +- **Setup:** Set `areaChoice = "lexicon"`, + `currentContentControl = "lexiconEdit"` in `PropertyTable`. + +--- + +## Subdomain 3: Slice Refresh & Reuse (`DataTree.SliceManagement.cs`) + +### Existing Tests + +None directly test reuse. `OwnedObjects` test exercises +`CreateSlices` indirectly. + +### New Tests Needed + +#### 3.1 `CreateSlices_SliceReuse_SameRootRefresh` +- **What:** After initial `ShowObject`, capture slice references. Call + `RefreshList(false)`. Verify that slices with matching keys are + reused (same .NET object reference). +- **Assertions:** `object.ReferenceEquals(oldSlice, newSlice)` for + matching keys. +- **Fixture:** Simple 2-slice layout. + +#### 3.2 `CreateSlices_DifferentObject_NoReuse` +- **What:** Call `ShowObject` with object A, then with object B. Verify + no reuse (old slices disposed). +- **Assertions:** All old slice references have `IsDisposed == true`. + +#### 3.3 `ObjSeqHashMap_RetrievalByKey` +- **What:** Unit test `ObjSeqHashMap` directly — insert keyed slices, + retrieve by key, verify removed entries are correct. +- **Note:** This is a standalone data-structure test, could go in a + new `ObjSeqHashMapTests.cs`. + +#### 3.4 `ObjSeqHashMap_ClearUnwantedPart_DifferentObject` +- **What:** After `ClearUnwantedPart(true)`, most entries are cleared. + Verify aggressive cleanup. + +#### 3.5 `ObjSeqHashMap_ClearUnwantedPart_SameObject` +- **What:** After `ClearUnwantedPart(false)`, entries are preserved + for reuse. + +#### 3.6 `MonitoredProps_AccumulatesAcrossRefresh` +- **What:** Call `ShowObject`, note `m_monitoredProps` count. Call + `RefreshList`. Verify props are NOT cleared (they accumulate). +- **Risk documentation:** This is by design but could leak memory over + very long sessions — document in test comment. + +--- + +## Subdomain 4: Navigation & Focus (`DataTree.Navigation.cs`) + +### Existing Tests + +None. + +### New Tests Needed + +#### 4.1 `GotoFirstSlice_SetsCurrentSliceToFirstFocusable` +- **What:** With 5 slices (first is a header, rest are data), call + `GotoFirstSlice()`. Verify `CurrentSlice` is the first data slice + (skipping the header). +- **Fixture:** `Nested-Expanded` layout produces a header + children. + +#### 4.2 `GotoNextSlice_AdvancesToNextFocusable` +- **What:** Set `CurrentSlice` to slice[1], call `GotoNextSlice()`. + Verify `CurrentSlice` is slice[2]. + +#### 4.3 `GotoNextSlice_AtEnd_DoesNotChange` +- **What:** Set `CurrentSlice` to the last slice, call + `GotoNextSlice()`. Verify `CurrentSlice` is unchanged. + +#### 4.4 `GotoPreviousSliceBeforeIndex_ReversesNavigation` +- **What:** Set `CurrentSlice` to slice[2], call + `GotoPreviousSliceBeforeIndex(2)`. Verify `CurrentSlice` is + slice[1] (or the nearest focusable before index 2). + +#### 4.5 `FocusFirstPossibleSlice_DescendantSpecific` +- **What:** Set `m_descendant` to a specific owned object. Verify + `FocusFirstPossibleSlice` selects a slice belonging to that + descendant, not the first slice overall. + +#### 4.6 `FocusFirstPossibleSlice_FallsBackToFirstFocusable` +- **What:** Set `m_descendant` to an object without a matching slice. + Verify focus falls back to the first focusable slice. + +#### 4.7 `CurrentSlice_Setter_NullThrows` +- **What:** `CurrentSlice = null` throws `ArgumentException`. +- **Assertions:** `Assert.Throws`. + +#### 4.8 `CurrentSlice_Setter_FiresChangedEvent` +- **What:** Subscribe to `CurrentSliceChanged`, set `CurrentSlice`. + Verify event fires. + +#### 4.9 `CurrentSlice_Setter_SuspendedStoresInNew` +- **What:** During `m_fSuspendSettingCurrentSlice`, setting + `CurrentSlice` only stores in `m_currentSliceNew`. + +#### 4.10 `DescendantForSlice_RootLevelSlice_ReturnsRoot` +- **What:** For a slice with no `ParentSlice`, `DescendantForSlice` + returns `m_root`. + +#### 4.11 `DescendantForSlice_NestedSlice_ReturnsHeaderObject` +- **What:** For a slice under a header, `DescendantForSlice` returns + the header's object. + +#### 4.12 `MakeSliceRealAt_RealizeDummySlice` +- **What:** At an index containing a `DummyObjectSlice`, calling + `MakeSliceRealAt` replaces it with a real slice. +- **Assertions:** After call, `Slices[i].IsRealSlice == true`. + +--- + +## Subdomain 5: Messaging & IxCoreColleague (`DataTree.Messaging.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `OnDisplayShowHiddenFields_CheckedState_...` | Menu display | +| 2 | `OnPropertyChanged_ShowHiddenFields_Toggles...` | Property toggle | + +### New Tests Needed + +#### 5.1 `GetMessageTargets_VisibleWithCurrentSlice` +- **What:** With a visible DataTree and a current slice, verify + `GetMessageTargets()` returns `[currentSlice, this]`. +- **Assertions:** Array length == 2; `[0]` is the slice. + +#### 5.2 `GetMessageTargets_NotVisible_ReturnsSliceOnly` +- **What:** Hide DataTree, verify `GetMessageTargets()` returns + only `[currentSlice]` (or empty if no current slice). + +#### 5.3 `GetMessageTargets_NoCurrentSlice_ReturnsSelfOnly` +- **What:** Visible DataTree, no current slice. Returns `[this]`. + +#### 5.4 `ShouldNotCall_WhenDisposed_ReturnsTrue` +- **What:** After `Dispose()`, `ShouldNotCall` returns `true`. + +#### 5.5 `PropChanged_MonitoredProp_TriggersRefresh` +- **What:** Register `MonitorProp(hvo, tag)`. Fire `PropChanged` with + that pair. Verify `RefreshListAndFocus` is called (or verify the + observable effect: slices are rebuilt). + +#### 5.6 `PropChanged_UnmonitoredProp_NoFullRefresh` +- **What:** Fire `PropChanged` with an unregistered `(hvo, tag)`. + Verify no full refresh occurs. + +#### 5.7 `PropChanged_UnmonitoredDuringUndo_RootVectorChange_FullRefresh` +- **What:** Set up an undo-in-progress state. Fire `PropChanged` on + the root object's owning sequence. Verify `RefreshList(true)`. + +#### 5.8 `DeepSuspendResumeLayout_Counting` +- **What:** Call `DeepSuspendLayout()` twice, then `DeepResumeLayout()` + twice. Verify layout is only resumed on the second resume call. +- **Assertions:** After first resume, layout is still suspended. + +#### 5.9 `OnPropertyChanged_UnknownProperty_NoOp` +- **What:** Call `OnPropertyChanged("someRandomProperty")`. Verify no + side effects (no refresh, no exception). + +#### 5.10 `PostponePropChanged_DefersRefresh` +- **What:** Set `m_postponePropChanged = true` (via the event), fire + `PropChanged` on a monitored prop. Verify `BeginInvoke` is used + rather than a synchronous call. +- **Note:** Hard to test directly; may need to verify via a flag or + mock the control's BeginInvoke. + +--- + +## Subdomain 6: Lifecycle & Persistence (`DataTree.cs` core) + +### Existing Tests + +None directly test lifecycle. Setup/teardown exercises init + dispose. + +### New Tests Needed + +#### 6.1 `Initialize_SetsRequiredFields` +- **What:** After `Initialize(cache, false, layouts, parts)`, verify + `m_cache`, `m_mdc`, `m_sda` are set. + +#### 6.2 `Dispose_UnsubscribesNotifications` +- **What:** After `Dispose()`, verify `m_sda.RemoveNotification` was + called (the DataTree no longer receives property changes). + +#### 6.3 `Dispose_CurrentSlice_GetsDeactivated` +- **What:** Set a current slice, then `Dispose()`. Verify the slice + received `SetCurrentState(false)`. + +#### 6.4 `Dispose_DoubleDispose_IsNoOp` +- **What:** Call `Dispose()` twice. No exception thrown. + +#### 6.5 `CheckDisposed_AfterDispose_Throws` +- **What:** After `Dispose()`, calling `CheckDisposed()` throws + `ObjectDisposedException`. + +--- + +## Subdomain 7: JumpToTool (`DataTree.Messaging.cs`) + +### Existing Tests + +| # | Test | Covers | +|---|------|--------| +| 1 | `GetGuidForJumpToTool_UsesRootObject_WhenNoCurrentSlice` | Null current slice → root GUID | + +### New Tests Needed + +#### 7.1 `GetGuidForJumpToTool_ConcordanceTool_LexEntry` +- **What:** With `tool = "concordance"`, current slice on a `LexEntry`. + Verify the resolved GUID is the entry's GUID. + +#### 7.2 `GetGuidForJumpToTool_ConcordanceTool_LexSense` +- **What:** Current slice on a `LexSense` under the entry. Verify + the GUID is the sense's GUID. + +#### 7.3 `GetGuidForJumpToTool_LexiconEditTool` +- **What:** With `tool = "lexiconEdit"`. Verify the GUID resolution + walks ownership to find the owning `LexEntry`. + +#### 7.4 `GetGuidForJumpToTool_ForEnableOnly_DoesNotCreate` +- **What:** With `forEnableOnly = true` and a tool that might create + an object (e.g., `notebookEdit`). Verify no object creation occurs. + +--- + +## Test Count Summary + +| Subdomain | Existing | New | Total | +|-----------|----------|-----|-------| +| XML Layout Parsing | 8 | 19 | 27 | +| ShowObject & Show-Hidden | 8 | 9 | 17 | +| Slice Refresh & Reuse | 0 | 6 | 6 | +| Navigation & Focus | 0 | 12 | 12 | +| Messaging & IxCoreColleague | 2 | 10 | 12 | +| Lifecycle & Persistence | 0 | 5 | 5 | +| JumpToTool | 1 | 4 | 5 | +| **Total** | **19** | **65** | **84** | + +--- + +## Priority Order for Implementation + +1. **Navigation & Focus** (12 tests) — zero coverage today, critical + for user-facing behavior during Avalonia migration +2. **Slice Refresh & Reuse** (6 tests) — zero coverage, `ObjSeqHashMap` + is a complex data structure with subtle semantics +3. **XML Layout Parsing** (19 new tests) — partial coverage exists + but the threshold logic, `ifData` variants, and ghost slices are + unprotected +4. **Messaging** (10 new tests) — `PropChanged` and + `DeepSuspendLayout` have re-entrancy risks +5. **ShowObject extensions** (9 new tests) — good existing coverage, + but root-change and no-op paths are untested +6. **Lifecycle** (5 tests) — simple but important for dispose safety +7. **JumpToTool** (4 tests) — lower risk, specialized feature diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md new file mode 100644 index 0000000000..0837cfbcb1 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms-future.md @@ -0,0 +1,241 @@ +# Test Plan: Headless DataTree Model (Approach 3 — Future) + +## Goal + +Fully separate the model layer from the view layer so that all +layout-engine logic, slice metadata computation, visibility rules, +navigation, and property-change handling can be tested as pure C# +classes with **zero WinForms dependency**. + +This is the end-state architecture for the `datatree-model-view` +branch. It builds on Approach 2 (the `IDataTreePainter` seam) and +replaces the current god-class `DataTree : UserControl` with a +model+view pair. + +--- + +## 1. Architecture + +### 1.1 Target Decomposition + +``` +┌──────────────────────────────────────────────┐ +│ DataTreeView : UserControl │ +│ - Hosts WinForms controls (SplitContainers) │ +│ - Painting (OnPaint, DrawLabel) │ +│ - Focus management │ +│ - Scroll position │ +│ - Delegates to DataTreeModel for all logic │ +└──────────┬───────────────────────────────────┘ + │ uses +┌──────────▼───────────────────────────────────┐ +│ DataTreeModel (plain C#, no UI dependency) │ +│ - Layout engine: XML → List │ +│ - Visibility rules (ifdata, never, hidden) │ +│ - Navigation (current index, next, prev) │ +│ - ObjSeqHashMap (slice reuse) │ +│ - Property change → refresh decisions │ +│ - Expand/collapse state │ +│ - MonitoredProps tracking │ +└──────────────────────────────────────────────┘ +``` + +### 1.2 SliceSpec Data Object + +The core output of the layout engine, replacing the current Slice +class for model-level concerns: + +```csharp +public class SliceSpec +{ + public string Label { get; set; } + public string Abbreviation { get; set; } + public int Indent { get; set; } + public int Flid { get; set; } + public string FieldName { get; set; } + public string EditorType { get; set; } + public SliceVisibility Visibility { get; set; } + public ObjectWeight Weight { get; set; } + public int ObjectHvo { get; set; } + public Guid ObjectGuid { get; set; } + public XmlNode ConfigurationNode { get; set; } + public ArrayList Key { get; set; } + public Type SliceType { get; set; } // e.g. typeof(StringSlice) +} +``` + +### 1.3 New Project + +``` +SIL.FieldWorks.Common.Controls.DetailControls.Model +├── DataTreeModel.cs +├── SliceSpec.cs +├── SliceVisibility.cs +├── LayoutEngine.cs // XML → SliceSpec list +├── NavigationModel.cs // current, next, prev +├── SliceReuseMap.cs // extracted from ObjSeqHashMap +└── VisibilityRules.cs // ifdata, never, show-hidden +``` + +**Dependencies:** `SIL.LCModel`, `System.Xml` — no `System.Windows.Forms`. + +--- + +## 2. Extraction Strategy + +### Phase 1: Extract Layout Engine + +The core pure function buried in DataTree: + +``` +(XML layouts, XML parts, LCM cache, object, layoutName) + → ordered List +``` + +This is currently spread across: +- `DataTree.CreateSlicesFor` (line ~1800) +- `DataTree.ProcessPartChildren` (line ~2000) +- `DataTree.AddAtomicNode` (line ~2420) +- `DataTree.AddSeqNode` (line ~2500) +- `DataTree.MakeGhostSlice` (line ~2458) +- `SliceFactory.Create` (external) + +**Extraction approach:** Create `LayoutEngine.ComputeSliceSpecs(...)` that +returns `List`. DataTree calls this and then creates actual +WinForms Slice controls from the specs. + +### Phase 2: Extract Navigation Model + +Currently: `CurrentSlice`, `GotoNextSlice`, `GotoPreviousSliceBeforeIndex`, +`FocusFirstPossibleSlice` are all on DataTree and mix model state +(which slice is current) with view behavior (focus, scrolling). + +**Extraction:** `NavigationModel` holds the current index and provides +`MoveNext()`, `MovePrevious()`, `MoveToFirst()` operating on +`List`. The view layer translates model index changes into +focus/scroll actions. + +### Phase 3: Extract Visibility Rules + +Currently: visibility logic is scattered across `ShowObject`, +`HandleShowHiddenFields`, `ProcessPartChildren`, and individual slice +checks. + +**Extraction:** `VisibilityRules.ShouldShow(SliceSpec, bool showHidden)` +is a pure function. The view layer calls it during layout. + +### Phase 4: Extract Slice Reuse + +`ObjSeqHashMap` already exists as a separate class. Rename to +`SliceReuseMap`, clean up its API, and make it part of the model +project. Already 90% covered by existing tests. + +--- + +## 3. Test Strategy + +### 3.1 Golden-File / Snapshot Tests + +For each real layout in `DistFiles/Language Explorer/Configuration/`: + +1. Run `LayoutEngine.ComputeSliceSpecs(cache, layout, testObject)` +2. Serialize the result to JSON: `[{label, indent, editorType, flid, objectClass}]` +3. Compare against a golden file checked into the repo + +**Benefits:** +- Tests the real combinatorial complexity (``, ``, + ``, ``, multi-level ownership) +- No WinForms, no Form, no Graphics +- Regressions are immediately visible as golden-file diffs +- Covers thousands of lines of XML parsing logic + +### 3.2 Unit Tests for Pure Model Classes + +```csharp +[Test] +public void LayoutEngine_CfOnly_ProducesOneSliceSpec() +{ + var engine = new LayoutEngine(layouts, parts); + var specs = engine.ComputeSliceSpecs(cache, entry, "CfOnly"); + + Assert.That(specs.Count, Is.EqualTo(1)); + Assert.That(specs[0].Label, Is.EqualTo("Citation Form")); + Assert.That(specs[0].EditorType, Is.EqualTo("multistring")); +} + +[Test] +public void NavigationModel_MoveNext_AdvancesIndex() +{ + var nav = new NavigationModel(specs); + nav.MoveTo(0); + nav.MoveNext(); + Assert.That(nav.CurrentIndex, Is.EqualTo(1)); +} + +[Test] +public void VisibilityRules_IfData_EmptyField_ReturnsFalse() +{ + var spec = new SliceSpec { Visibility = SliceVisibility.IfData }; + // ... setup empty field + Assert.That(VisibilityRules.ShouldShow(spec, cache, showHidden: false), + Is.False); +} +``` + +### 3.3 Integration Tests (from testing-approach-2.md §2.5) + +Small number of end-to-end tests that exercise real workflows: + +1. **Show → Edit → Refresh**: ShowObject, modify citation form via + LCM, trigger PropChanged, verify slice list is refreshed correctly +2. **Large list + scroll**: 30+ senses, verify DummyObjectSlice → + BecomeReal at correct indices +3. **Toggle show-hidden**: Flip property, verify correct slices appear/disappear +4. **Switch objects**: Show entry A, then show entry B, verify clean + slate with correct slices + +These tests use `DataTreeModel` directly — no WinForms needed. + +--- + +## 4. Migration Path from Approach 2 + +| Approach 2 artifact | Evolves into | +|---------------------|-------------| +| `IDataTreePainter` interface | View-layer contract | +| `RecordingPainter` test double | View-layer test infrastructure | +| `OffscreenGraphicsContext` | View-layer test infrastructure | +| `HandlePaintLinesBetweenSlices` (internal) | `DataTreeView.PaintLines(...)` | +| `HandleLayout1` (protected internal) | `DataTreeView.PerformSliceLayout(...)` | +| Behavioral contract tests | Move to model test project | +| Static helpers (IsChildSlice, SameSourceObject) | `LayoutEngine` or `NavigationModel` | + +Tests written under Approach 2 with `[Category("SurvivesRefactoring")]` +are expected to survive by moving to the model test project with +minimal changes. + +--- + +## 5. Timeline and Prerequisites + +| Step | Prerequisite | Effort | +|------|-------------|--------| +| Approach 2 (current plan) | None | Small (current sprint) | +| Phase 1: Layout Engine extraction | Approach 2 done, high test coverage | Large (dedicated sprint) | +| Phase 2: Navigation Model | Phase 1 | Medium | +| Phase 3: Visibility Rules | Phase 1 | Medium | +| Phase 4: Slice Reuse cleanup | Phase 1 | Small | +| Golden-file test suite | Phase 1 | Medium | +| DataTreeView refactoring | Phases 1–4 | Large | + +--- + +## 6. Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Layout engine extraction is complex (4700-line class) | High | Incremental: extract one method at a time, verify coverage after each | +| XML config nodes are used by both model and view | High | SliceSpec captures all config data needed by view; config nodes stay in model | +| SliceFactory creates actual WinForms controls | High | Split into SpecFactory (model) + ControlFactory (view) | +| Real layouts may have undocumented edge cases | Medium | Golden-file tests catch regressions from day one | +| Performance regression from extra allocation | Low | SliceSpec is a simple POCO; profile if needed | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md new file mode 100644 index 0000000000..aeabb3441f --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-forms.md @@ -0,0 +1,239 @@ +# Test Plan: Offscreen UI & Rendering Tests (Approach 2) + +## Goal + +Exercise DataTree and Slice painting, layout, visibility, and control +behavior without showing windows on screen. Uses bitmap-backed +`Graphics`, `SetVisibleCore` reflection, and a small interface +extraction to create a testable seam for rendering. + +This plan also incorporates actionable, non-breaking improvements from +`testing-approach-2.md` §2.1–§2.6. + +--- + +## 1. Production Code Changes (Non-Breaking) + +### 1.1 Extract `IDataTreePainter` Interface + +A small interface that abstracts the line-drawing logic between slices. +DataTree implements it as its default behavior. Tests can substitute a +recording implementation. + +```csharp +// New file: IDataTreePainter.cs +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Abstracts the painting operations that DataTree performs between + /// slices, allowing tests to intercept/record draw calls without a + /// real screen. + /// + public interface IDataTreePainter + { + /// + /// Paint separator lines between slices. + /// + void PaintLinesBetweenSlices(Graphics gr, int width); + } +} +``` + +**DataTree changes:** +- Add `public IDataTreePainter Painter { get; set; }` property, + defaulting to `this` in the constructor. +- DataTree implements `IDataTreePainter` explicitly. +- `OnPaint` delegates to `Painter.PaintLinesBetweenSlices(...)` instead + of calling `HandlePaintLinesBetweenSlices` directly. +- `HandlePaintLinesBetweenSlices` becomes `internal` (was `private`). + +This is backward-compatible: existing code sees no difference because +the Painter defaults to the DataTree itself. + +### 1.2 Make Private Methods Internal + +The following methods change from `private` to `internal` to allow +direct testing via the existing `InternalsVisibleTo("DetailControlsTests")`: + +| Method | Current | New | Rationale | +|--------|---------|-----|-----------| +| `HandlePaintLinesBetweenSlices` | private | internal | Direct bitmap test | +| `SameSourceObject` | private static | internal static | Unit test paint logic | +| `IsChildSlice` | private static | internal static | Unit test paint logic | + +### 1.3 Test Category Attributes + +Add `[Category]` attributes to existing and new tests for lifespan +tracking during the refactoring (from testing-approach-2.md §2.6): + +| Category | Meaning | +|----------|---------| +| `SurvivesRefactoring` | Tests behavioral contracts preserved across model/view split | +| `PreRefactoring` | Tests documenting current internals; expected to be rewritten | +| `KnownBug` | Tests that document bugs as current behavior | +| `OffscreenUI` | Tests exercising painting/layout/visibility without a screen | + +--- + +## 2. Test Infrastructure + +### 2.1 `OffscreenGraphicsContext` (test helper) + +Disposable helper that creates a `Bitmap` + `Graphics` + synthetic +`PaintEventArgs` for offscreen rendering: + +```csharp +internal class OffscreenGraphicsContext : IDisposable +{ + public Bitmap Bitmap { get; } + public Graphics Graphics { get; } + + public OffscreenGraphicsContext(int width = 800, int height = 600) + { + Bitmap = new Bitmap(width, height); + Graphics = Graphics.FromImage(Bitmap); + } + + public PaintEventArgs CreatePaintEventArgs() + { + return new PaintEventArgs(Graphics, + new Rectangle(0, 0, Bitmap.Width, Bitmap.Height)); + } + + public PaintEventArgs CreatePaintEventArgs(Rectangle clip) + { + return new PaintEventArgs(Graphics, clip); + } + + public void Dispose() + { + Graphics?.Dispose(); + Bitmap?.Dispose(); + } +} +``` + +Pattern inspired by `VwGraphicsTests.GraphicsObjectFromImage` which +already uses `Bitmap(1000,1000)` + `Graphics.FromImage` for offscreen +VwGraphics testing. + +### 2.2 `RecordingPainter` (test double) + +Records all paint operations for assertion without drawing: + +```csharp +internal class RecordingPainter : IDataTreePainter +{ + public List<(Point From, Point To, float PenWidth)> DrawnLines { get; } = new(); + public int PaintCallCount { get; private set; } + + public void PaintLinesBetweenSlices(Graphics gr, int width) + { + PaintCallCount++; + // Optionally record using a recording Graphics wrapper + } +} +``` + +### 2.3 Visibility Helper (existing) + +Already implemented in `DataTreeTests`: +```csharp +SetControlVisibleForTest(Control control, bool visible) +// Uses reflection to call Control.SetVisibleCore(bool) +``` + +This makes controls report `Visible=true` without `Form.Show()`. + +--- + +## 3. Test Plan + +### 3.1 Slice.DrawLabel — Bitmap Rendering + +| # | Test | Asserts | +|---|------|---------| +| 1 | `DrawLabel_WithLabel_DrawsToGraphics` | Call `DrawLabel(0, 0, gr, 800)` on a Slice with `Label="Test"`. Verify no exception and that the bitmap has non-white pixels. | +| 2 | `DrawLabel_WithAbbreviation_UsesAbbrevWhenNarrow` | Set `SplitCont.SplitterDistance` ≤ `MaxAbbrevWidth`. Verify `DrawLabel` uses `Abbreviation` text. | +| 3 | `DrawLabel_WithSmallImages_DrawsIconBeforeText` | Set `SmallImages` to a real `ImageCollection`. Verify icon is drawn at (x,y) and text starts after icon width. | +| 4 | `DrawLabel_NullLabel_DoesNotThrow` | `Label = null`, call `DrawLabel`. Document current behavior (likely NRE — characterize it). | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.2 DataTree.HandlePaintLinesBetweenSlices — Offscreen Lines + +| # | Test | Asserts | +|---|------|---------| +| 5 | `HandlePaintLinesBetweenSlices_TwoSlices_DrawsLine` | Initialize DataTree + ShowObject with "CfAndBib" layout (2 slices). Force visibility. Create bitmap Graphics + PaintEventArgs. Call `HandlePaintLinesBetweenSlices`. Verify bitmap has drawn pixels between slice positions. | +| 6 | `HandlePaintLinesBetweenSlices_SingleSlice_DrawsNothing` | ShowObject with "CfOnly" (1 slice). Verify no lines drawn. | +| 7 | `HandlePaintLinesBetweenSlices_HeaderSlice_SkipsLine` | Configure a slice with `header="true"` attribute. Verify line is skipped per the existing logic. | +| 8 | `PaintLinesBetweenSlices_ViaInterface_DelegatesToPainter` | Set `Painter` to `RecordingPainter`. Trigger paint. Assert `PaintCallCount == 1`. | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.3 DataTree Layout — HandleLayout1 with Forced Visibility + +| # | Test | Asserts | +|---|------|---------| +| 9 | `HandleLayout1_PositionsSlicesVertically` | Initialize + ShowObject. `SetVisibleCore(true)` on parent and DataTree. Call `HandleLayout1(true, ClientRectangle)`. Assert each slice's `Top` is greater than previous slice's `Top + Height`. | +| 10 | `HandleLayout1_HeavyWeightSlice_AddsMargin` | Create a slice with `Weight = ObjectWeight.heavy`. Verify the gap includes `HeavyweightRuleThickness + HeavyweightRuleAboveMargin`. | + +**Category:** `OffscreenUI`, `SurvivesRefactoring` + +### 3.4 Behavioral Contract Tests (from testing-approach-2.md §2.1) + +These test invariants that survive the model/view split: + +| # | Test | Contract | +|---|------|----------| +| 11 | `ShowObject_ProducesCorrectSliceOrder` | Given layout XML + object → correct ordered list of labels | +| 12 | `VisibilityIfData_HidesWhenEmpty` | `visibility="ifdata"` hides when data is empty | +| 13 | `ShowHiddenFields_TogglesVisibility` | Setting ShowHiddenFields property shows/hides "never" fields | +| 14 | `Expand_CreatesChildSlices` | Expanding a collapsed node creates child slices in correct order | +| 15 | `Collapse_RemovesChildSlices` | Collapsing removes descendant slices from the tree | + +**Category:** `SurvivesRefactoring` + +### 3.5 Static Helper Tests (IsChildSlice, SameSourceObject) + +| # | Test | Asserts | +|---|------|---------| +| 16 | `IsChildSlice_MatchingPrefix_ReturnsTrue` | Two slices where second's key extends first's | +| 17 | `IsChildSlice_DifferentKeys_ReturnsFalse` | Non-matching key prefixes | +| 18 | `SameSourceObject_SameHvo_ReturnsTrue` | Same Object.Hvo | +| 19 | `SameSourceObject_DifferentHvo_ReturnsFalse` | Different Object.Hvo | + +**Category:** `SurvivesRefactoring` + +--- + +## 4. File Layout + +| File | Contents | +|------|----------| +| `IDataTreePainter.cs` | Interface (production, DetailControls project) | +| `DataTree.cs` | Implement interface, add Painter property, widen visibility | +| `DataTreeTests.Wave4.OffscreenUI.cs` | Tests §3.1–3.5 above | +| `OffscreenGraphicsContext.cs` | Test helper (DetailControlsTests project) | +| `RecordingPainter.cs` | Test double (DetailControlsTests project) | + +--- + +## 5. Implementation Order + +1. Create `IDataTreePainter.cs` interface +2. DataTree: implement interface, add `Painter` property, widen method visibility +3. Create `OffscreenGraphicsContext.cs` test helper +4. Create `RecordingPainter.cs` test double +5. Create `DataTreeTests.Wave4.OffscreenUI.cs` with initial tests +6. Build + verify all existing tests still pass +7. Run coverage assessment to measure improvement + +## 6. Risks and Mitigations + +| Risk | Mitigation | +|------|------------| +| `SetVisibleCore` may trigger unintended side effects | Already proven safe in existing Wave3 tests | +| Bitmap-based Graphics may differ from screen Graphics | We test structure (pixels drawn vs. not drawn), not exact rendering | +| Interface extraction could break callers | Default implementation is DataTree itself — fully backward compatible | +| Tests may be fragile on CI (headless Windows) | WinForms handle creation works without a desktop session on Windows | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md new file mode 100644 index 0000000000..8d22c10e91 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-harness.md @@ -0,0 +1,302 @@ +# Test Harness & Fixture Plan + +## Purpose + +This document details the test infrastructure enhancements needed to +support the 129 new characterization tests across DataTree and Slice. +It covers: fixture XML additions, helper method needs, base class +choices, and standalone data-structure test files. + +--- + +## 1. Existing Test Infrastructure + +### Base Class +- `MemoryOnlyBackendProviderRestoredForEachTestTestBase` — provides + per-test `LcmCache` with in-memory backend. Both `DataTreeTests` + and `SliceTests` inherit from this. + +### Fixture-Level Setup (DataTreeTests) +- `GenerateLayouts()` — scans `*Tests/*.fwlayout` for layout XML +- `GenerateParts()` — scans `*Tests/*Parts.xml` for part XML +- Both produce `Inventory` objects used to initialize `DataTree` + +### Per-Test Setup (DataTreeTests) +``` +CustomFieldForTest → "testField" on LexEntry +ILexEntry → CitationForm="rubbish", Bibliography="My rubbishy..." +DataTree + Mediator + PropertyTable +Form hosting DataTree +``` + +### Shared Helpers +- `SliceTests.CreateXmlElementFromOuterXmlOf(string)` — parses XML + string to `XmlElement`. Used by `SliceTests` and `SliceFactoryTests`. +- `SliceTests.GenerateSlice(cache, datatree)` — creates a configured + Slice with parent DataTree. +- `SliceTests.GeneratePath()` — returns 7-element ArrayList mimicking + a real slice Key path. + +--- + +## 2. New XML Fixture Additions + +### 2.1 `Test.fwlayout` — New Layouts Needed + +Add the following layouts to the existing fixture file: + +| Layout Name | Class | Purpose | Parts | +|-------------|-------|---------|-------| +| `ManySenses` | `LexEntry` | Test kInstantSliceMax threshold (>20 items) | `` | +| `ManySensesExpanded` | `LexEntry` | Test threshold override with `expansion="expanded"` | `` | +| `EmptySeq` | `LexEntry` | Test empty sequence (no items) | `` | +| `GhostAtomic` | `LexEntry` | Test ghost slice creation for null atomic | `` | +| `IfNotTest` | `LexEntry` | Test `` conditional | `` child parts | +| `ChoiceTest` | varies | Test `//` | Multiple `` clauses | +| `OwnerLabel` | `LexSense` | Test `$owner.` label prefix | `` | +| `WeightTest` | `LexEntry` | Test `weight` attribute parsing | Parts with `weight="heavy"`, `weight="light"` | +| `IfDataMultiString` | `LexEntry` | Test ifdata for multistring fields | Parts with `visibility="ifdata" ws="analysis"` | +| `IfDataStText` | `LexEntry` | Test ifdata for StText empty paragraph | Part referencing a StText field | +| `NavigationTest` | `LexEntry` | 5+ slices for navigation testing | 5 simple `` refs | +| `HeaderWithChildren` | `LexEntry` | Header + 3 children for focus tests | Nested header with children | + +### 2.2 `TestParts.xml` — New Parts Needed + +| Part ID | Purpose | +|---------|---------| +| `LexEntry-Detail-Pronunciation` | Atomic field for ghost slice test | +| `LexEntry-Detail-ManySenses` | Sequence with senses | +| Navigation parts (5x) | Simple string slices for nav tests | +| Header parts | Header + children configuration | + +--- + +## 3. New Helper Methods + +### 3.1 DataTreeTests Helpers + +#### `CreateEntryWithSenses(int count)` +```csharp +private ILexEntry CreateEntryWithSenses(int count) +{ + var entry = Cache.ServiceLocator.GetInstance() + .Create(); + var senseFactory = Cache.ServiceLocator + .GetInstance(); + for (int i = 0; i < count; i++) + senseFactory.Create(entry); + return entry; +} +``` +**Used by:** ManySenses threshold tests, navigation tests. + +#### `ShowObjectAndGetSlices(string layoutName, ILexEntry entry = null)` +```csharp +private List ShowObjectAndGetSlices( + string layoutName, ICmObject obj = null) +{ + obj ??= m_entry; + m_dtree.ShowObject(obj, layoutName, null, obj, false); + return m_dtree.Slices.ToList(); +} +``` +**Used by:** Most characterization tests. + +#### `AssertSliceLabels(params string[] expectedLabels)` +```csharp +private void AssertSliceLabels(params string[] expectedLabels) +{ + var actual = m_dtree.Slices + .Select(s => s.Label).ToArray(); + CollectionAssert.AreEqual(expectedLabels, actual); +} +``` +**Used by:** Layout parsing tests, navigation tests. + +#### `AssertSliceCount(int expected)` +```csharp +private void AssertSliceCount(int expected) +{ + Assert.AreEqual(expected, m_dtree.Controls.Count, + $"Expected {expected} slices but found " + + $"{m_dtree.Controls.Count}"); +} +``` + +#### `SimulateShowHidden(string toolName, bool show)` +```csharp +private void SimulateShowHidden(string toolName, bool show) +{ + m_propertyTable.SetProperty( + $"ShowHiddenFields-{toolName}", show, true); +} +``` + +### 3.2 SliceTests Helpers + +#### `CreateSliceWithConfig(string xmlConfig)` +```csharp +private Slice CreateSliceWithConfig(string xmlConfig) +{ + var slice = new Slice(); + slice.ConfigurationNode = + CreateXmlElementFromOuterXmlOf(xmlConfig); + return slice; +} +``` + +#### `InstallSliceInDataTree(Slice slice, DataTree dt = null)` +```csharp +private void InstallSliceInDataTree(Slice slice, DataTree dt) +{ + dt ??= m_DataTree; + slice.Cache = Cache; + slice.Install(dt); +} +``` + +--- + +## 4. New Test Files + +### 4.1 `ObjSeqHashMapTests.cs` +- **Location:** `DetailControlsTests/ObjSeqHashMapTests.cs` +- **Purpose:** Unit tests for the `ObjSeqHashMap` data structure in + isolation (no DataTree, no Form, no WinForms). +- **Base class:** `NUnit.Framework.TestFixture` (no LCM needed) +- **Tests:** + +| # | Test | What | +|---|------|------| +| 1 | `Add_And_Retrieve` | Add keyed slices, retrieve by key | +| 2 | `Remove_ReturnsCorrectSlice` | Remove returns the first match | +| 3 | `ClearUnwantedPart_True_ClearsAll` | `differentObject=true` clears | +| 4 | `ClearUnwantedPart_False_PreservesEntries` | `differentObject=false` preserves | +| 5 | `DuplicateKeys_FIFO` | Multiple slices with same key → FIFO retrieval | +| 6 | `MissingKey_ReturnsNull` | Retrieving nonexistent key returns null | + +### 4.2 `SliceFactoryTests.cs` (Existing — Extend) +- **Current coverage:** 1 test (`SetConfigurationDisplayPropertyIfNeeded`) +- **New tests:** + +| # | Test | What | +|---|------|------| +| 1 | `Create_MultistringEditor_ReturnsMultiStringSlice` | Factory dispatch for `editor="multistring"` | +| 2 | `Create_StringEditor_ReturnsStringSlice` | Factory dispatch for `editor="string"` | +| 3 | `Create_UnknownEditor_ReflectionFallback` | Custom editor class resolved via reflection | +| 4 | `Create_NullEditor_ReturnsBasicSlice` | No editor attribute → default Slice | + +--- + +## 5. Test Configuration Updates + +### 5.1 `DetailControlsTests.csproj` +The project uses SDK-style format with auto-inclusion. No changes +needed unless new test files are placed outside the project directory. +Verify that `ObjSeqHashMapTests.cs` auto-includes. + +### 5.2 `Test.runsettings` +No changes needed. The existing settings file applies to all managed +tests via `.\test.ps1`. + +--- + +## 6. Test Execution Strategy + +### Phase 0 Implementation Order + +1. **Week 1:** Add XML fixtures + helper methods (foundation) +2. **Week 2:** DataTree Navigation tests (12 tests, zero coverage) +3. **Week 2:** ObjSeqHashMap standalone tests (6 tests) +4. **Week 3:** DataTree Reuse tests (6 tests, depends on ObjSeqHashMap) +5. **Week 3:** DataTree Messaging tests (10 tests) +6. **Week 4:** XML Layout Parsing tests (19 tests) +7. **Week 4:** Slice Core + Lifecycle tests (19 tests) +8. **Week 5:** Slice Menu Commands tests (14 tests) +9. **Week 5:** Remaining Slice tests (31 tests) +10. **Week 6:** ShowObject extensions + JumpToTool (13 tests) + +### Running Tests +```powershell +# All managed tests +.\test.ps1 + +# Just DetailControls tests +.\test.ps1 -Filter "DetailControls" + +# Specific test class +.\test.ps1 -Filter "DataTreeTests" +``` + +--- + +## 7. Risk Areas Identified During Research + +### 7.1 `m_monitoredProps` Never Cleared +The `HashSet<(int, int)>` accumulates across `RefreshList` calls and +is only nulled during `Dispose`. This means entries from previous +slice builds remain. Tests should document this behavior (not fix it +in Phase 0) via a comment: +```csharp +// NOTE: m_monitoredProps accumulates by design. Entries from +// previous ShowObject/RefreshList calls persist until Dispose. +// This could theoretically cause unnecessary refreshes but +// has not been observed as a problem in practice. +``` + +### 7.2 `GetFlidIfPossible` Static Cache Collision +The static `Dictionary` keyed by `"className-fieldName"` +could collide if two classes have identically-named fields with +different flids. Tests should document this: +```csharp +// NOTE: GetFlidIfPossible uses a static cache keyed by +// "className-fieldName". If two classes define a field with +// the same name but different flids, the cache returns the +// first one seen. This is a latent bug but has no known +// manifestation in the current schema. +``` + +### 7.3 `BeginInvoke` in `PropChanged` +When `m_postponePropChanged` is true, `PropChanged` calls +`BeginInvoke(RefreshListAndFocus)`. This is hard to test in NUnit +because `BeginInvoke` requires a Windows message pump. Tests for +this path should either: +- Use a `Form.Show()` + `Application.DoEvents()` pattern, or +- Document the limitation and test only the synchronous path. + +### 7.4 `SelectAt(99999)` Heuristic +`SetDefaultCurrentSlice` calls `SelectAt(99999)` on +`MultiStringSlice`/`StringSlice` to place the cursor at the end. +The magic number is a convention (any large value works). Tests +should verify cursor-at-end behavior without relying on the specific +value. + +--- + +## 8. Cross-Cutting: Tests Needed for Phase 2+ Classes + +These tests are NOT Phase 0 characterization tests but are listed +here for completeness. They will be created when their respective +classes are extracted. + +| Class | File | Test Count | Notes | +|-------|------|------------|-------| +| `SliceLayoutBuilder` | `SliceLayoutBuilderTests.cs` | ~15 | Can test without Form/WinForms | +| `ShowHiddenFieldsManager` | `ShowHiddenFieldsManagerTests.cs` | ~6 | Pure logic, no UI deps | +| `DataTreeNavigator` | `DataTreeNavigatorTests.cs` | ~8 | May need mock Slice list | +| `DataTreeModel` | `DataTreeModelTests.cs` | ~10 | Core Phase 3 tests; no WinForms | +| `SliceSpec` | `SliceSpecTests.cs` | ~5 | Data class, trivial | + +--- + +## Summary + +| Category | File | Tests | +|----------|------|-------| +| DataTree characterization | `DataTreeTests.cs` | 65 new | +| Slice characterization | `SliceTests.cs` | 64 new | +| ObjSeqHashMap unit | `ObjSeqHashMapTests.cs` | 6 new | +| SliceFactory extensions | `SliceFactoryTests.cs` | 4 new | +| **Phase 0 Total** | | **139 new tests** | +| Phase 2+ (future) | Various | ~44 | +| **Grand Total** | | **183** | diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md new file mode 100644 index 0000000000..8c51bc1d9a --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/test-plan-slice.md @@ -0,0 +1,379 @@ +# Slice Characterization Test Plan + +## Purpose + +This document details every characterization test needed for `Slice.cs` +(3,341 lines) before the partial-class split. Tests are organized by +proposed partial-class file. Each entry documents existing vs. needed +tests, edge cases, and assertions. + +All tests go in `Src/Common/Controls/DetailControls/DetailControlsTests/SliceTests.cs` +unless otherwise noted. + +--- + +## Existing Test Coverage + +`SliceTests.cs` currently has **6 tests**, all minimal smoke-tests: + +| # | Test | Coverage Level | +|---|------|----------------| +| 1 | `Basic1` | Constructor not null | +| 2 | `Basic2` | Constructor with control | +| 3 | `CreateIndentedNodes_basic` | Smoke (no assertion beyond no-crash) | +| 4 | `Expand` | Smoke | +| 5 | `Collapse` | Smoke | +| 6 | `CreateGhostStringSlice_ParentSliceNotNull` | Ghost slice PropTable propagation | + +**Assessment:** Coverage is minimal. No tests assert actual behaviors +like label rendering, expansion state transitions, delete/merge/split +eligibility, help topic resolution, or field visibility. + +--- + +## Subdomain 1: Core Properties (`Slice.Core.cs`) + +### New Tests Needed + +#### 1.1 `Abbreviation_AutoGeneratedFromLabel` +- **What:** Set `Label = "Citation Form"`, leave `Abbreviation` unset. + Verify `Abbreviation` returns `"Cita"` (first 4 chars). +- **Edge case:** Label shorter than 4 chars → full label is abbreviation. + +#### 1.2 `Abbreviation_ExplicitOverridesAutoGeneration` +- **What:** Set `Label = "Citation Form"`, + `Abbreviation = "CF"`. Verify `Abbreviation` returns `"CF"`. + +#### 1.3 `IsSequenceNode_TrueForOwningSequence` +- **What:** Configure slice with `` where + `Senses` is an `OwningSequence` field. Verify + `IsSequenceNode == true`. + +#### 1.4 `IsSequenceNode_FalseForCollection` +- **What:** Configure slice with `` where the field + type is an unordered collection. Verify `IsSequenceNode == false`. + +#### 1.5 `IsCollectionNode_TrueForNonSequence` +- **What:** Same as 1.4 but verify `IsCollectionNode == true` + (complement of `IsSequenceNode`). + +#### 1.6 `IsHeaderNode_ReadsXmlAttribute` +- **What:** Set `ConfigurationNode` with `header="true"`. Verify + `IsHeaderNode == true`. + +#### 1.7 `ContainingDataTree_NullWhenOrphaned` +- **What:** Create a slice not parented to any DataTree. Verify + `ContainingDataTree` returns null. + +#### 1.8 `ContainingDataTree_ReturnsParent` +- **What:** After `Install(dataTree)`, verify `ContainingDataTree` + returns the DataTree. + +#### 1.9 `WrapsAtomic_ReadsConfigAttribute` +- **What:** Set `ConfigurationNode` with `wrapsAtomic="true"`. Verify + `WrapsAtomic == true`. + +#### 1.10 `CallerNodeEqual_StructuralComparison` +- **What:** Set two `CallerNode` values with identical XML content but + different .NET references. Verify `CallerNodeEqual` returns true. + +--- + +## Subdomain 2: Lifecycle (`Slice.Lifecycle.cs`) + +### New Tests Needed + +#### 2.1 `Constructor_SetsVisibleFalse` +- **What:** After `new Slice()`, verify `Visible == false`. +- **Rationale:** All slices start invisible to prevent reordering + during `Controls.Add`. + +#### 2.2 `Install_SetsHeightToMaxOfControlAndLabel` +- **What:** Install a slice with `Control.Height = 40` and + `LabelHeight = 20`. Verify `Height == 40`. +- **What:** Install a slice with `Control.Height = 10` and + `LabelHeight = 20`. Verify `Height == 20`. + +#### 2.3 `Install_NoLabel_FixesSplitterAtOnePixel` +- **What:** Install a slice with empty label. Verify the splitter is + fixed (Panel1 is collapsed or at minimum width). + +#### 2.4 `Install_SetsPanelMinSizeFromIndent` +- **What:** Set `Indent = 2`. After `Install`, verify + `Panel1MinSize == (20 * (2+1)) + 20 = 80`. + +#### 2.5 `CheckDisposed_AfterDispose_Throws` +- **What:** Dispose slice, call `CheckDisposed()`. Verify + `ObjectDisposedException`. + +#### 2.6 `Dispose_NullsAllFields` +- **What:** After `Dispose(true)`, verify key fields are null + (prevents use-after-dispose). + +#### 2.7 `Dispose_UnsubscribesSplitterEvent` +- **What:** After disposal, the splitter moved event should not fire + (no dangling event handlers). + +#### 2.8 `OnEnter_DuringConstruction_DoesNotSetCurrentSlice` +- **What:** When `ContainingDataTree.ConstructingSlices` is true, + `OnEnter` should not set `CurrentSlice`. + +#### 2.9 `BecomeReal_BaseReturnsSelf` +- **What:** On a non-dummy slice, `BecomeReal(0)` returns `this`. + +--- + +## Subdomain 3: Tree Rendering (`Slice.TreeRendering.cs`) + +### New Tests Needed + +#### 3.1 `DrawLabel_UsesAbbreviationWhenNarrow` +- **What:** With `SplitCont.SplitterDistance <= MaxAbbrevWidth (60)`, + `DrawLabel` should render the abbreviation, not the full label. +- **Note:** Requires a `Graphics` object; may be a visual/smoke test. + +#### 3.2 `LabelIndent_Calculation` +- **What:** Verify `LabelIndent()` returns + `kdxpLeftMargin + (Indent+1) * kdxpIndDist`. For `Indent = 0`, + verify the expected pixel value. + +#### 3.3 `Expand_SetsStateToExpanded` +- **What:** Set `Expansion = ktisCollapsed`. Call `Expand()`. Verify + `Expansion == ktisExpanded`. + +#### 3.4 `Expand_PersistsExpansionState` +- **What:** After `Expand()`, verify the `ExpansionStateKey` is stored + in `PropertyTable` as `"true"`. + +#### 3.5 `Collapse_SetsStateToCollapsed` +- **What:** Set `Expansion = ktisExpanded` with children. Call + `Collapse()`. Verify `Expansion == ktisCollapsed`. + +#### 3.6 `Collapse_PersistsCollapsedState` +- **What:** After `Collapse()`, verify the `ExpansionStateKey` is + removed or set to `"false"` in `PropertyTable`. + +#### 3.7 `Collapse_RemovesDescendantSlices` +- **What:** After `Collapse()`, verify all descendant slices (those + with this slice as an ancestor via `ParentSlice`) are removed from + the DataTree. + +#### 3.8 `ExpansionStateKey_NullForFixedSlices` +- **What:** A slice with `Expansion = ktisFixed` returns null for + `ExpansionStateKey`. + +#### 3.9 `IsObjectNode_TrueForNodeChild` +- **What:** Config with `` child and object != root → + `IsObjectNode == true`. + +#### 3.10 `IsObjectNode_FalseForSeqChild` +- **What:** Config with `` child → `IsObjectNode == false` + (sequence, not object node). + +--- + +## Subdomain 4: Child Generation (`Slice.ChildGeneration.cs`) + +### New Tests Needed + +#### 4.1 `GenerateChildren_NothingResult_SetsFixed` +- **What:** When `NodeTestResult = kntrNothing`, `GenerateChildren` + should set `Expansion = ktisFixed`. + +#### 4.2 `GenerateChildren_PossibleResult_SetsCollapsedEmpty` +- **What:** When `NodeTestResult = kntrPossible` and not previously + expanded, `GenerateChildren` sets + `Expansion = ktisCollapsedEmpty`. + +#### 4.3 `GenerateChildren_PersistentExpansion_RestoresExpanded` +- **What:** Store `ExpansionStateKey = "true"` in PropertyTable before + `GenerateChildren`. Verify expansion is restored to `ktisExpanded`. + +#### 4.4 `ExtraIndent_TrueAttribute_ReturnsOne` +- **What:** `Slice.ExtraIndent(node)` with `indent="true"` returns 1. + +#### 4.5 `ExtraIndent_NoAttribute_ReturnsZero` +- **What:** `Slice.ExtraIndent(node)` with no `indent` attr returns 0. + +--- + +## Subdomain 5: Menu Commands (`Slice.MenuCommands.cs`) + +### New Tests Needed + +#### 5.1 `GetCanDeleteNow_RequiredAtomicField_ReturnsFalse` +- **What:** Object in a required atomic field (CellarPropertyType + check). Verify `GetCanDeleteNow()` returns false. + +#### 5.2 `GetCanDeleteNow_VectorWithMultipleItems_ReturnsTrue` +- **What:** Object in a vector field with >1 items. Verify + `GetCanDeleteNow()` returns true. + +#### 5.3 `GetCanDeleteNow_VectorWithOneItem_Required_ReturnsFalse` +- **What:** Object in a required vector with exactly 1 item. Verify + `GetCanDeleteNow()` returns false. + +#### 5.4 `GetCanMergeNow_SubsensesAlwaysMergeable` +- **What:** A subsense (`LexSense` owned by another `LexSense`) + should always be mergeable. Verify `GetCanMergeNow()` returns true. + +#### 5.5 `GetCanMergeNow_AllomorphsSameClass_Mergeable` +- **What:** Allomorphs can merge with lexeme form if same class. + Verify `GetCanMergeNow()` returns true when classes match. + +#### 5.6 `GetCanMergeNow_NeedsTwoSameClassSiblings` +- **What:** Fewer than 2 items of the same class in the vector → + `GetCanMergeNow()` returns false. + +#### 5.7 `GetCanSplitNow_SameClassOwner_ReturnsTrue` +- **What:** Owner is same class as object → can split. + +#### 5.8 `GetCanSplitNow_VectorLessThanTwo_ReturnsFalse` +- **What:** Vector with <2 items and different-class owner → false. + +#### 5.9 `GetObjectForMenusToOperateOn_WrapsAtomic` +- **What:** When `WrapsAtomic` is true, follows the atomic field to + return the owned object. + +#### 5.10 `GetObjectForMenusToOperateOn_VariantBackRef` +- **What:** When `FromVariantBackRefField` is true, returns the + back-reference object. + +#### 5.11 `GetSeqContext_WalksKeyBackward` +- **What:** Set a Key with 7 elements (the standard path). Verify + `GetSeqContext` returns the correct owning hvo, flid, and position. + +#### 5.12 `GetSeqContext_GhostSlice_HandledCorrectly` +- **What:** Key ends with ghost-slice markers (FWR-556). Verify + `GetSeqContext` still finds the correct sequence context. + +#### 5.13 `StartsWith_BoxedIntEquality` +- **What:** `Slice.StartsWith(key1, key2)` where keys contain boxed + `int` values. Verify correct equality despite boxing. + +#### 5.14 `HandleDeleteCommand_SelectsNearbySlice` +- **What:** After deleting a slice's object, verify a nearby slice + gets focus (not null, not the deleted one). + +--- + +## Subdomain 6: Field Configuration (`Slice.FieldConfiguration.cs`) + +### New Tests Needed + +#### 6.1 `SetFieldVisibility_PersistsOverride` +- **What:** Call `SetFieldVisibility("never")`. Verify the override + is persisted via `Inventory` and the DataTree is refreshed. + +#### 6.2 `SetFieldVisibility_SameValue_NoOp` +- **What:** When the field already has the requested visibility, + `SetFieldVisibility` should not refresh. + +#### 6.3 `GetSibling_Up_SkipsChildren` +- **What:** With a slice at depth 2 under a parent at depth 1, calling + `GetSibling(Direction.Up)` should skip child slices and return the + previous depth-1 sibling. + +#### 6.4 `GetSibling_Down_SkipsChildren` +- **What:** Symmetric test for downward sibling. + +#### 6.5 `GetSibling_AtBoundary_ReturnsNull` +- **What:** For the first/last slice at a given depth, `GetSibling` + returns null. + +#### 6.6 `HelpTopicResolution_FourLevelFallback` +- **What:** `GenerateHelpTopicId` tries 4 patterns: + tool+class+field → tool+SortKey+field → tool+field → + class+field → field. Provide a mock `IHelpTopicProvider` that + validates only the 3rd pattern. Verify the 3rd ID is returned. + +#### 6.7 `HelpTopicResolution_CrossRefVsLexicalRelation` +- **What:** For `Targets` field, help topic varies based on whether + the parent is a cross-reference or lexical relation (uses `parentHvo` + to distinguish). Verify both paths. + +#### 6.8 `ReplacePartWithNewAttribute_PropagatesAcrossSlices` +- **What:** After calling `ReplacePartWithNewAttribute`, verify that + all slices in the DataTree that share the same `part ref` node have + their Key updated to point to the new XML node. + +--- + +## Subdomain 7: IxCoreColleague (`Slice.XCoreColleague.cs`) + +### New Tests Needed + +#### 7.1 `GetMessageTargets_VisibleWithColleagueControl` +- **What:** When Control implements `IxCoreColleague` and the slice + is visible, `GetMessageTargets()` returns `[Control, this]`. + +#### 7.2 `GetMessageTargets_Hidden_ReturnsEmpty` +- **What:** When slice is not visible and DataTree is not visible, + returns empty array. + +#### 7.3 `Init_SetsMediator_InitsColleagueControl` +- **What:** If Control is an `IxCoreColleague`, calling `Init` on + the slice also calls `Init` on the Control. + +#### 7.4 `SetCurrentState_PropagatesActiveToParents` +- **What:** Call `SetCurrentState(true)`. Verify the `Active` flag + propagates up the `ParentSlice` chain to the header. + +#### 7.5 `SetCurrentState_False_DeactivatesChain` +- **What:** Call `SetCurrentState(false)`. Verify the chain is + deactivated. + +--- + +## Subdomain 8: Splitter & Layout (`Slice.SplitterLayout.cs`) + +### New Tests Needed + +#### 8.1 `SetSplitPosition_UsesBaseAndIndent` +- **What:** Set `DataTree.SliceSplitPositionBase = 100`, + `Indent = 2`. Verify `SplitCont.SplitterDistance == + 100 + LabelIndent()`. + +#### 8.2 `OnSizeChanged_PreservesScrollPosition` +- **What:** Simulate `OnSizeChanged` and verify scroll position is + preserved (LT-18750 regression test). + +#### 8.3 `SetWidthForDataTreeLayout_MarksFlag` +- **What:** After calling `SetWidthForDataTreeLayout(500)`, verify + `m_widthHasBeenSetByDataTree` is true and the width is 500. + +--- + +## Test Count Summary + +| Subdomain | Existing | New | Total | +|-----------|----------|-----|-------| +| Core Properties | 2 | 10 | 12 | +| Lifecycle | 0 | 9 | 9 | +| Tree Rendering | 0 | 10 | 10 | +| Child Generation | 2 | 5 | 7 | +| Menu Commands | 0 | 14 | 14 | +| Field Configuration | 0 | 8 | 8 | +| IxCoreColleague | 0 | 5 | 5 | +| Splitter & Layout | 0 | 3 | 3 | +| **Total** | **6** (incl. ghost) | **64** | **70** | + +--- + +## Priority Order for Implementation + +1. **Menu Commands** (14 tests) — Delete/Merge/Split eligibility is + user-facing CRUD behavior with complex class-hierarchy logic; + zero coverage today +2. **Core Properties** (10 tests) — Property semantics (abbreviation, + node types, containment) underpin all other subdomains +3. **Lifecycle** (9 tests) — Dispose safety and construction invariants + prevent use-after-dispose bugs during migration +4. **Tree Rendering** (10 tests) — Expand/Collapse + persistence + protects user state across refresh +5. **Child Generation** (5 tests) — Expansion state machine transitions + are subtle and layout-dependent +6. **Field Configuration** (8 tests) — Visibility persistence and + sibling navigation used by field-reordering UI +7. **IxCoreColleague** (5 tests) — Message routing correctness +8. **Splitter & Layout** (3 tests) — Lower risk, mostly cosmetic diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md new file mode 100644 index 0000000000..0977639178 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-characterization-tests/testing-approach-2.md @@ -0,0 +1,339 @@ +# DataTree Testing Approach — Critical Analysis + +## Context + +DataTree is a ~4,700-line god class that combines XML layout parsing, slice +lifecycle management, WinForms control hosting, navigation, mediator messaging, +property change notifications, persistence, and domain-specific jump-to-tool +commands. The current testing effort is writing "characterization tests" before +a planned model/view separation refactoring. + +This document asks two questions: + +1. **Devil's advocate:** Why might these tests fail to provide meaningful safety + during the refactoring? +2. **How could the testing be made more effective?** + +--- + +## Part 1: Devil's Advocate — Why These Tests May Not Measure What Matters + +### 1.1 Reflection-heavy tests are testing the lock, not the door + +A significant number of Wave 3 tests reach into private fields and methods via +`typeof(DataTree).GetField(...)` / `.GetMethod(...)`: + +- `m_currentSlice`, `m_currentSliceNew`, `m_postponePropChanged`, + `m_rch`, `m_listName`, `m_rlu`, `m_currentObjectFlids`, `m_monitoredProps` +- Private methods like `PostponePropChanged`, `RestorePreferences`, + `SetCurrentSliceNewFromObject`, `CreateAndAssociateNotebookRecord`, + `DescendantForSlice`, `m_rch_Disposed`, `RefreshList(int, int)` + +**The problem:** The whole point of the refactoring is to _change the internals_. +Tests that are coupled to field names and method signatures will break the moment +you rename, extract, or restructure — which is exactly what a model/view split +does. These tests don't protect behavior; they protect implementation topology. +They'll tell you "you moved a field" not "you broke a user-visible behavior." + +When the refactoring starts, you'll face a choice: delete most of these tests +(eliminating the safety net) or port them (spending effort adapting tests that +weren't testing outcomes in the first place). + +### 1.2 Assert.DoesNotThrow is a code smell, not a specification + +Multiple tests follow this pattern: + +```csharp +Assert.DoesNotThrow(() => m_dtree.OnFocusFirstPossibleSlice(null)); +Assert.DoesNotThrow(() => m_dtree.FixRecordList()); +Assert.DoesNotThrow(() => m_dtree.GotoFirstSlice()); +``` + +"It doesn't crash" is the weakest possible assertion. It means: +- You don't know what the method is _supposed_ to do +- You can't detect silent regressions (wrong state, lost data, no-op where + action was expected) +- The test will pass even after a refactoring that guts the method body to + `return;` + +These tests create an illusion of safety while catching almost nothing that +matters. + +### 1.3 Property getter/setter round-trips test the C# language, not the class + +Tests like: + +```csharp +public void SmallImages_SetterAndGetterRoundTrip() { ... } +public void StyleSheet_SetterAllowsNullRoundTrip() { ... } +public void PersistenceProvider_SetterAndGetterRoundTrip() { ... } +public void SliceSplitPositionBase_SetterUpdatesValue() { ... } +public void DoNotRefresh_GetterReflectsSetter() { ... } +``` + +These verify that `{ get; set; }` works. The C# compiler already guarantees +this. Unless the property has side effects (like DoNotRefresh triggering a +deferred refresh), testing plain auto-properties adds coverage percentage +without adding confidence. + +### 1.4 "Characterization" without behavioral contracts is just archaeology + +A characterization test is supposed to document _what the system actually does_ +so regressions are detectable. But many of these tests document trivia: + +- `Priority_ReturnsMediumColleaguePriority` — will this constant ever change + during a model/view split? No. Does it matter? Only if the mediator cares. +- `ShouldNotCall_FalseByDefault` — this is a one-liner property. +- `SliceControlContainer_ReturnsSelf` — trivially true for any class that + implements the interface by returning `this`. +- `LabelWidth_ReturnsExpectedConstant` — magic number `40` hardcoded in both + production code and test. + +The real danger in the refactoring is in the _interactions_: what happens when +ShowObject triggers RefreshList which reuses slices via ObjSeqHashMap which +depends on key equivalence which uses EquivalentKeys with XML node comparison. +That chain is barely tested as an integrated flow. + +### 1.5 The test harness itself is a mock of reality + +The tests use `MemoryOnlyBackendProviderRestoredForEachTestTestBase` with +a minimal `Inventory` of parts and layouts (loaded from test XML files in the +test directory). This is appropriate for unit tests, but it means: + +- **Layout complexity is synthetic.** The real layouts in `DistFiles/` have + ``, ``, ``, `` nesting, custom editors, + and multi-level ownership chains. The test layouts are 2–3 part configs. +- **Slice types are limited.** The test setup creates generic `Slice` objects. + In production, DataTree creates `ReferenceVectorSlice`, `PhonologicalRuleFormulaSlice`, + `StTextSlice`, and dozens of others via editor reflection. None of these + appear in the tests. +- **No real WinForms hosting.** Tests use `m_parent = new Form()` without + `.Show()`, so layout, painting, focus, and visibility checks never fire + properly. Navigation tests (`GotoNextSlice`, `GotoPreviousSliceBeforeIndex`) + are testing navigation logic in a context where no slice is actually visible + or focusable. + +The refactoring risk isn't "does DataTree work with 2-slice test layouts?" +It's "does DataTree work with the 47 real layouts that FieldWorks ships?" + +### 1.6 Coverage percentage ≠ confidence + +The test plan targets 84 tests across 7 subdomains and claims priority based +on "zero coverage today." But coverage metrics count lines hit, not behaviors +verified. A test that calls `ShowObject` and asserts `Slices.Count > 0` "covers" +thousands of lines of XML parsing, slice creation, and reuse logic — without +actually verifying any of it works correctly. + +The most dangerous code paths are: +- Re-entrant `PropChanged` → `RefreshListAndFocus` → `CreateSlices` while + layout is suspended +- `ObjSeqHashMap` reuse across refresh with stale keys +- `DummyObjectSlice.BecomeReal` mid-scroll +- Thread safety of `m_fSuspendSettingCurrentSlice` + +None of these are addressed by the current tests in a way that would catch +a regression. + +### 1.7 Tests document bugs as "expected behavior" + +Several tests explicitly lock down questionable behavior: + +- `OnJumpToLexiconEditFilterAnthroItems_WithoutCurrentSlice_ThrowsNullReferenceException` + — This test asserts that a NullReferenceException is thrown. That's a bug, + not a feature. Characterizing it means you'll need to keep the NRE after + refactoring, or remember to delete the test. +- `MonitoredProps_AccumulatesAcrossRefresh_CurrentBehavior` — Documents a + potential memory leak. The companion `[Explicit]` test acknowledges this + should be fixed, but the characterization test will _prevent_ fixing it + during refactoring because it asserts the leak persists. +- `AddAtomicNode_WhenFlidIsZero_ThrowsApplicationException` — Documents an + internal validation check that's only reachable through reflection. + +Characterizing bugs creates a maintenance drag: every bug you document is a +test you'll later need to update or delete when the bug is fixed. + +### 1.8 The refactoring will change the public API surface + +The model/view separation means DataTree will be split into (at least): +- A model layer (data, business rules, slice metadata) +- A view layer (WinForms controls, painting, focus) + +Tests that call `m_dtree.ShowObject()` and then check `m_dtree.Slices[0].Label` +are testing the _combined_ model+view. After the split, there won't be a single +object that does both. Every test that touches both "what slices exist" and +"what controls are visible" will need rewriting. + +If that's the case, these tests are a temporary scaffold — which is fine, but +the test plan doesn't acknowledge this. It presents them as long-lived safety +nets, which they won't be. + +--- + +## Part 2: How to Test More Effectively + +### 2.1 Identify the behavioral contracts that survive the refactoring + +Before writing more tests, answer: **what invariants must be preserved after +the model/view split?** These are the tests worth writing: + +| Contract | Survives refactoring? | +|----------|----------------------| +| Given layout XML + object → correct ordered list of (label, type, indent, object) | Yes — this is the model | +| `visibility="ifdata"` hides when data is empty | Yes | +| `visibility="never"` hides unless show-hidden is on | Yes | +| Refreshing same object reuses matching slice metadata | Maybe — depends on design | +| MonitorProp + PropChanged → refresh | Yes — notification contract | +| Navigation order matches slice order | Yes — but implementation changes | +| JumpToTool resolves correct GUID | Yes — business logic | +| Context menu handler dispatch | Probably yes | + +Tests for the "Yes" contracts are worth investing in. Tests for internal +mechanics (ObjSeqHashMap, EquivalentKeys, DummyObjectSlice) will break. + +### 2.2 Test at the right boundary: layout → slice metadata + +Instead of testing `DataTree` as a god object, extract the testable core: + +**The layout engine is a pure function:** +``` +(XML layouts, XML parts, LCM object, layout name) → ordered list of SliceSpec +``` + +Where `SliceSpec` is a data object: `{ Label, Indent, FieldName, Flid, EditorType, +Visibility, ObjectHvo, ConfigurationNode }`. + +If you extract this function (even before the full refactoring), you can: +- Test it without WinForms +- Test it without a parent Form +- Test with real production layouts from `DistFiles/` +- Assert on structured data instead of control tree state +- Keep the tests across the refactoring because the function signature is stable + +This is the single most impactful change: **separate "what slices should exist" +from "how are they rendered."** + +### 2.3 Use production layouts as golden-file tests + +The test plan uses synthetic 2-part layouts. This misses the combinatorial +complexity of real layouts. A more effective approach: + +1. Run DataTree with each real layout (from `DistFiles/Language Explorer/Configuration/`) + and a representative LCM object +2. Serialize the resulting slice list to a golden file: + `{ label, indent, type, object class, flid }` +3. Diff against the golden file after each code change + +This gives you whole-system regression coverage without writing individual +assertions. It's the classic characterization test pattern — snapshot testing — +and it's far more robust than hand-written assertions for each branch. + +### 2.4 Replace reflection-based access with testable seams + +Instead of: +```csharp +var field = typeof(DataTree).GetField("m_currentSlice", BindingFlags.Instance | BindingFlags.NonPublic); +field.SetValue(m_dtree, slice); +``` + +Create `internal` methods or use `[InternalsVisibleTo]` (already available +since the tests are in the same assembly area). Better yet, extract the logic +into a separate class where the state is part of the public contract: + +```csharp +// Instead of testing DataTree's private field directly: +var nav = new SliceNavigator(slices); +nav.MoveTo(slice); +Assert.That(nav.Current, Is.SameAs(slice)); +``` + +This way, the tests survive extraction because they're testing the extracted +class directly. + +### 2.5 Write integration tests that exercise real user workflows + +The most dangerous regressions in a refactoring are the ones where "each piece +works but the whole doesn't." Consider a small number of end-to-end tests: + +1. **Show a LexEntry, edit citation form, verify refresh** — exercises + ShowObject → slice creation → PropChanged → RefreshList → slice reuse +2. **Show an entry with 30 senses, scroll to sense 25** — exercises + DummyObjectSlice → BecomeReal → layout +3. **Toggle show-hidden-fields on/off** — exercises property change → + HandleShowHiddenFields → slice visibility +4. **Switch from one entry to another** — exercises slice disposal → + new slice creation → focus management + +These four tests cover more real-world risk than 50 getter/setter checks. + +### 2.6 Explicitly tag tests by lifespan + +Not all characterization tests are equal. Tag them: + +- **`[Category("SurvivesRefactoring")]`** — Tests behavioral contracts that + should pass before _and_ after the model/view split. +- **`[Category("PreRefactoring")]`** — Tests that document current internals + and are expected to be deleted/rewritten during the split. +- **`[Category("KnownBug")]`** — Tests that document bugs (NRE on null current + slice, etc.) which should become `Assert.DoesNotThrow` or be deleted after fix. + +This makes the test suite actionable during the refactoring instead of a wall +of red that requires triage. + +### 2.7 Don't test constants and trivial properties + +Remove or don't write tests for: +- `Priority_ReturnsMediumColleaguePriority` (constant) +- `ShouldNotCall_FalseByDefault` (trivial) +- `SliceControlContainer_ReturnsSelf` (identity) +- `LabelWidth_ReturnsExpectedConstant` (constant) +- `SmallImages_SetterAndGetterRoundTrip` (auto-property) +- `StyleSheet_SetterAllowsNullRoundTrip` (auto-property) + +These inflate test count and coverage without detecting regressions. The time +spent writing and maintaining them is better spent on the behavioral contracts +listed in §2.1. + +### 2.8 Address the ObjSeqHashMap gap directly + +`ObjSeqHashMap` is the most critical data structure for the refactoring (it +controls slice reuse during refresh), yet it has zero direct tests. The test +plan mentions this (§3.3–3.5) but no tests have been written. + +This should be the highest priority: extract `ObjSeqHashMap` into its own file +with its own test fixture, and test: +- Insert → retrieve by key +- ClearUnwantedPart(true) vs ClearUnwantedPart(false) +- Key collision behavior +- Behavior with disposed slices in the map + +These tests are cheap, fast, and directly relevant to the refactoring. + +--- + +## Summary + +| Issue | Severity | Recommendation | +|-------|----------|----------------| +| Reflection-coupled tests break on refactoring | High | Extract testable seams; use internal visibility | +| Assert.DoesNotThrow tests catch nothing | High | Replace with specific outcome assertions | +| Getter/setter round-trips are wasteful | Medium | Delete; focus on behavioral contracts | +| Synthetic layouts miss real complexity | High | Add golden-file tests with production layouts | +| Tests document bugs as expected behavior | Medium | Tag as `[Category("KnownBug")]`; plan for fixes | +| ObjSeqHashMap untested | High | Standalone test fixture, highest priority | +| No test lifespan tagging | Medium | Add categories for refactoring lifecycle | +| No integrated workflow tests | High | Write 4–5 end-to-end scenarios | + +### Bottom line + +The current testing effort is building _coverage_ but not _confidence_. Many +tests are tightly coupled to internals that will change, assert trivial +properties, or document bugs as specifications. The test plan optimizes for +"number of tests" when it should optimize for "number of behavioral contracts +preserved across the refactoring." + +The most effective changes are: +1. Extract the layout-to-slice-metadata function and test it in isolation +2. Add golden-file tests with real production layouts +3. Test `ObjSeqHashMap` directly +4. Tag tests by expected lifespan +5. Stop writing getter/setter and `DoesNotThrow` tests diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md new file mode 100644 index 0000000000..44f631f655 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-model/spec.md @@ -0,0 +1,118 @@ +## Status Note (Current vs Target) + +This document defines the **target-state requirements** for the model/view separation. +On the current branch snapshot (2026-02-28), `DataTreeModel`, `IDataTreeView`, `SliceLayoutBuilder`, and `ShowHiddenFieldsManager` are not yet implemented in production code; `IDataTreePainter` is implemented. + +## ADDED Requirements + +### Requirement: DataTreeModel owns slice-specification logic + +A new class `DataTreeModel` SHALL exist with no dependency on `System.Windows.Forms`. It SHALL own the decision of which slices to display, in what order, with what configuration — given a root `ICmObject`, a layout name, and a layout-choice field. + +#### Scenario: DataTreeModel produces slice specifications without WinForms +- **WHEN** `DataTreeModel.BuildSliceSpecs(root, layoutName, layoutChoiceField)` is called +- **THEN** it returns an ordered list of `SliceSpec` descriptors without creating any WinForms controls + +#### Scenario: DataTreeModel is testable without a Form +- **WHEN** a unit test constructs a `DataTreeModel` with cache, layout inventory, and part inventory +- **THEN** no `Form`, `UserControl`, or WinForms host is required for the test to run + +### Requirement: SliceSpec descriptor captures all slice metadata + +A new class `SliceSpec` SHALL capture the information needed to materialize a slice in any UI framework: label, abbreviation, indent level, editor type, XML configuration node, field ID, object reference, visibility, weight, tooltip, and key path. + +#### Scenario: SliceSpec contains editor type +- **WHEN** a `SliceSpec` is produced from a `` with `editor="multistring"` +- **THEN** `SliceSpec.EditorType` equals `"multistring"` + +#### Scenario: SliceSpec contains label and indent +- **WHEN** a `SliceSpec` is produced from an indented part with `label="Citation Form"` +- **THEN** `SliceSpec.Label` equals `"Citation Form"` and `SliceSpec.Indent` reflects the nesting depth + +### Requirement: SliceLayoutBuilder performs XML layout interpretation + +A new class `SliceLayoutBuilder` SHALL encapsulate the XML layout parsing logic currently in `DataTree.CreateSlicesFor`, `ApplyLayout`, `ProcessSubpartNode`, `AddSimpleNode`, `AddSeqNode`, and `AddAtomicNode`. It SHALL produce `SliceSpec` lists rather than concrete `Slice` WinForms controls. + +#### Scenario: SliceLayoutBuilder interprets sequence nodes +- **WHEN** a layout contains `` and the entry has 3 senses +- **THEN** `SliceLayoutBuilder` produces `SliceSpec` entries for each sense's sub-layout + +#### Scenario: SliceLayoutBuilder handles ifData visibility +- **WHEN** a part has `visibility="ifdata"` and the field is empty +- **THEN** `SliceLayoutBuilder` excludes that `SliceSpec` from the output (unless show-hidden is on) + +### Requirement: ShowHiddenFieldsManager encapsulates show-hidden key resolution + +A new class `ShowHiddenFieldsManager` SHALL own the logic for resolving the tool-specific property key used for the "Show Hidden Fields" toggle. It SHALL be consumed by both `DataTreeModel` and `IDataTreeView` implementations. + +#### Scenario: Resolves key from currentContentControl +- **WHEN** `currentContentControl` is `"lexiconEdit"` and the root is `ILexEntry` +- **THEN** `ShowHiddenFieldsManager.GetKey()` returns `"ShowHiddenFields-lexiconEdit"` + +#### Scenario: Falls back to lexiconEdit for LexEntry roots +- **WHEN** `currentContentControl` is not set and the root is `ILexEntry` +- **THEN** `ShowHiddenFieldsManager.GetKey()` returns `"ShowHiddenFields-lexiconEdit"` + +### Requirement: IDataTreeView interface for platform-specific rendering + +An interface `IDataTreeView` SHALL define the contract between `DataTreeModel` and platform-specific view implementations. It SHALL include methods for materializing `SliceSpec` lists into visible controls, managing focus, and reporting user interactions. + +#### Scenario: WinForms DataTree implements IDataTreeView +- **WHEN** the existing `DataTree : UserControl` is adapted +- **THEN** it implements `IDataTreeView` and delegates "what to show" decisions to `DataTreeModel` + +#### Scenario: IDataTreeView does not expose WinForms types +- **WHEN** `IDataTreeView` is defined +- **THEN** it references only framework-agnostic types (`SliceSpec`, `ICmObject`, etc.) — no `Control`, `Form`, or `UserControl` in the interface + +### Requirement: DataTree delegates to DataTreeModel + +The existing `DataTree` WinForms class SHALL delegate `ShowObject`, show-hidden resolution, XML layout interpretation, and navigation state to `DataTreeModel`. It SHALL retain only WinForms-specific responsibilities: `OnLayout`, `OnPaint`, splitter management, `Control` hosting, and `SplitContainer` configuration. + +#### Scenario: ShowObject delegates to model +- **WHEN** `DataTree.ShowObject(root, layout, ...)` is called +- **THEN** it calls `DataTreeModel.BuildSliceSpecs(...)` and materializes the resulting `SliceSpec` list into WinForms `Slice` controls + +#### Scenario: DataTree retains WinForms layout +- **WHEN** the WinForms layout engine calls `OnLayout` +- **THEN** `DataTree` positions its child `Slice` controls using WinForms-specific APIs without involving `DataTreeModel` + +### Requirement: StTextDataTree subclass compatibility + +The `StTextDataTree` subclass in `InfoPane.cs` SHALL continue to function. Its overrides of `ShowObject` and `SetDefaultCurrentSlice` SHALL be adapted to work with the model/view split. + +#### Scenario: StTextDataTree overrides model behavior +- **WHEN** `StTextDataTree` needs to transform the root object before display +- **THEN** it overrides a model-layer method (or hook) rather than a view-layer `ShowObject` + +### Requirement: Slice reuse remains functional + +The `ObjSeqHashMap`-based slice reuse mechanism SHALL continue to work during `RefreshList`. `SliceSpec` keys SHALL be compatible with the existing `Slice.Key` array structure. + +#### Scenario: Refresh reuses existing slices +- **WHEN** `RefreshList` is called on the same object +- **THEN** slices whose `SliceSpec.Key` matches an existing slice are reused, not recreated + +## MODIFIED Requirements + +### Requirement: WinForms patterns — DataTree composition + +*(Modified from `architecture/ui-framework/winforms-patterns`)* + +DataTree SHALL follow a model/view composition pattern: `DataTreeModel` (UI-agnostic) decides what to display; `DataTree` (WinForms `UserControl`) renders it. This replaces the current monolithic pattern where a single `UserControl` owns both logic and rendering. + +#### Scenario: DataTree is a thin view +- **WHEN** a developer reads `DataTree.cs` (the WinForms view) +- **THEN** they see only WinForms layout, paint, and control hosting — no XML parsing or show-hidden logic + +## MODIFIED Requirements + +### Requirement: Layer model — detail-tree model sublayer + +*(Modified from `architecture/layers/layer-model`)* + +A "detail-tree model" sublayer SHALL exist between the UI shell and data access layers. `DataTreeModel` and `SliceLayoutBuilder` reside in this sublayer. They depend downward on `LcmCache` and `Inventory` (data access / configuration) and are consumed upward by `IDataTreeView` implementations (UI shell). + +#### Scenario: Model layer has no UI dependency +- **WHEN** the `DataTreeModel` class is compiled +- **THEN** it does not reference `System.Windows.Forms`, `Avalonia`, or any UI framework assembly diff --git a/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md b/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md new file mode 100644 index 0000000000..df47cdc0aa --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/specs/datatree-partial-split/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: Partial-class file decomposition of DataTree + +DataTree.cs SHALL be split into partial-class files organized by responsibility. Each file SHALL contain one logical concern. The runtime behavior SHALL remain identical — no method signatures, visibility, or logic changes. + +#### Scenario: File split preserves compilation +- **WHEN** DataTree.cs is split into partial-class files +- **THEN** the project compiles with zero errors and zero new warnings + +#### Scenario: File split preserves test results +- **WHEN** all existing DataTreeTests pass before the split +- **THEN** all existing DataTreeTests pass after the split with no modifications + +### Requirement: Partial-class file organization + +The following files SHALL be created, each containing the indicated `#region` or logical grouping: + +| File | Content | +|------|---------| +| `DataTree.cs` | Data members, constructor, `Initialize`, `Dispose`, `CheckDisposed`, core properties (`Root`, `Cache`, `StyleSheet`, `Mediator`, etc.) | +| `DataTree.SliceManagement.cs` | `InsertSlice`, `RemoveSlice`, `RawSetSlice`, `InstallSlice`, `ForceSliceIndex`, `ResetTabIndices`, `InsertSliceRange`, tooltip management | +| `DataTree.LayoutParsing.cs` | `CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode`, `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice`, `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight resolution methods | +| `DataTree.WinFormsLayout.cs` | `OnLayout`, `HandleLayout1`, `MakeSliceRealAt`, `MakeSliceVisible`, `OnPaint`, `HandlePaintLinesBetweenSlices`, `OnSizeChanged`, `IndexOfSliceAtY`, `HeightOfSliceOrNullAt`, `FieldAt`, `FieldOrDummyAt`, `AboutToCreateField` | +| `DataTree.Navigation.cs` | `CurrentSlice` property, `DescendantForSlice`, `GotoFirstSlice`, `GotoNextSlice`, `GotoNextSliceAfterIndex`, `GotoPreviousSliceBeforeIndex`, `LastSlice`, `FocusFirstPossibleSlice`, `SelectFirstPossibleSlice`, `ScrollCurrentAndIfPossibleSectionIntoView`, `SetDefaultCurrentSlice`, `ActiveControl` | +| `DataTree.Messaging.cs` | All `IxCoreColleague` implementation (`Init`, `GetMessageTargets`, `ShouldNotCall`, `Priority`) and all `On*` message handlers | +| `DataTree.Persistence.cs` | `PrepareToGoAway`, `PersistPreferences`, `RestorePreferences`, `SetCurrentSlicePropertyNames`, `ShowObject` entry point, `RefreshList`, `CreateSlices`, show-hidden fields logic | + +#### Scenario: Each file contains exactly one concern +- **WHEN** a developer opens `DataTree.LayoutParsing.cs` +- **THEN** they see only XML layout interpretation methods, not WinForms layout or messaging code + +### Requirement: Partial-class decomposition of Slice + +Slice.cs SHALL be similarly split into partial-class files. The exact file list SHALL be determined during implementation but MUST separate at minimum: core properties, installation/lifecycle, tree-node rendering, and child generation. + +#### Scenario: Slice file split preserves compilation +- **WHEN** Slice.cs is split into partial-class files +- **THEN** the project compiles with zero errors and all SliceTests pass unchanged diff --git a/openspec/changes/datatree-model-view-separation/tasks.md b/openspec/changes/datatree-model-view-separation/tasks.md new file mode 100644 index 0000000000..0a1086b3d6 --- /dev/null +++ b/openspec/changes/datatree-model-view-separation/tasks.md @@ -0,0 +1,156 @@ +## 1. Phase 0: Characterization Tests + +_Full test inventory in:_ +- `specs/datatree-characterization-tests/test-plan-datatree.md` (84 tests) +- `specs/datatree-characterization-tests/test-plan-slice.md` (70 tests) +- `specs/datatree-characterization-tests/test-plan-harness.md` (fixtures + helpers) + +### 1A. Test Infrastructure (Week 1) + +- [ ] 1.1 Add 12 new layouts to `Test.fwlayout` (ManySenses, ManySensesExpanded, EmptySeq, GhostAtomic, IfNotTest, ChoiceTest, OwnerLabel, WeightTest, IfDataMultiString, IfDataStText, NavigationTest, HeaderWithChildren) +- [ ] 1.2 Add corresponding part definitions to `TestParts.xml` for the new layouts +- [ ] 1.3 Add DataTreeTests helpers: `CreateEntryWithSenses(int)`, `ShowObjectAndGetSlices(string, ICmObject)`, `AssertSliceLabels(params string[])`, `AssertSliceCount(int)`, `SimulateShowHidden(string, bool)` +- [ ] 1.4 Add SliceTests helpers: `CreateSliceWithConfig(string)`, `InstallSliceInDataTree(Slice, DataTree)` +- [ ] 1.5 Create `DetailControlsTests/ObjSeqHashMapTests.cs` — 6 standalone data-structure tests (Add_And_Retrieve, Remove, ClearUnwantedPart true/false, DuplicateKeys, MissingKey) + +### 1B. DataTree Navigation Tests (Week 2, 12 tests — zero coverage today) + +- [ ] 1.6 `GotoFirstSlice_SetsCurrentSliceToFirstFocusable` — skips headers +- [ ] 1.7 `GotoNextSlice_AdvancesToNextFocusable` + `GotoNextSlice_AtEnd_DoesNotChange` +- [ ] 1.8 `GotoPreviousSliceBeforeIndex_ReversesNavigation` +- [ ] 1.9 `FocusFirstPossibleSlice_DescendantSpecific` + `FocusFirstPossibleSlice_FallsBackToFirstFocusable` +- [ ] 1.10 `CurrentSlice_Setter_NullThrows` + `CurrentSlice_Setter_FiresChangedEvent` + `CurrentSlice_Setter_SuspendedStoresInNew` +- [ ] 1.11 `DescendantForSlice_RootLevelSlice_ReturnsRoot` + `DescendantForSlice_NestedSlice_ReturnsHeaderObject` +- [ ] 1.12 `MakeSliceRealAt_RealizeDummySlice` + +### 1C. DataTree Reuse & Refresh Tests (Week 3, 6 tests) + +- [ ] 1.13 `CreateSlices_SliceReuse_SameRootRefresh` — verify same .NET object reference +- [ ] 1.14 `CreateSlices_DifferentObject_NoReuse` — old slices disposed +- [ ] 1.15 `MonitoredProps_AccumulatesAcrossRefresh` — document non-clearing behavior + +### 1D. DataTree Messaging Tests (Week 3, 10 tests) + +- [ ] 1.16 `GetMessageTargets_VisibleWithCurrentSlice` / `NotVisible` / `NoCurrentSlice` +- [ ] 1.17 `ShouldNotCall_WhenDisposed_ReturnsTrue` +- [ ] 1.18 `PropChanged_MonitoredProp_TriggersRefresh` + `PropChanged_UnmonitoredProp_NoFullRefresh` +- [ ] 1.19 `PropChanged_UnmonitoredDuringUndo_RootVectorChange_FullRefresh` +- [ ] 1.20 `DeepSuspendResumeLayout_Counting` — nested counting +- [ ] 1.21 `OnPropertyChanged_UnknownProperty_NoOp` +- [ ] 1.22 `PostponePropChanged_DefersRefresh` + +### 1E. DataTree XML Layout Parsing Tests (Week 4, 19 tests) + +- [ ] 1.23 `CreateSlicesFor_NullObject_ReturnsEmptySliceList` +- [ ] 1.24 `GetTemplateForObjLayout_ClassHierarchyWalk` + `GetTemplateForObjLayout_CmCustomItemUsesWsLayout` +- [ ] 1.25 `ProcessSubpartNode_SequenceWithMoreThanThresholdItems` (>20 senses → DummyObjectSlice) +- [ ] 1.26 `ProcessSubpartNode_ThresholdOverride_ExpandedCaller` + `_PersistentExpansion` + `_EmptySequence` +- [ ] 1.27 `AddSimpleNode_IfData_MultiString_EmptyAnalysis` + `_NonEmptyVernacular` +- [ ] 1.28 `AddSimpleNode_IfData_StText_EmptyParagraph` +- [ ] 1.29 `AddSimpleNode_IfData_Summary_SuppressesNode` +- [ ] 1.30 `ProcessSubpartNode_IfNotCondition` + `ProcessSubpartNode_ChoiceWhereOtherwise` +- [ ] 1.31 `AddAtomicNode_GhostSliceCreation` +- [ ] 1.32 `InterpretLabelAttribute_DollarOwnerPrefix` +- [ ] 1.33 `SetNodeWeight_ValidWeights` + `GetFlidIfPossible_CachingBehavior` + +### 1F. DataTree ShowObject & Lifecycle Tests (Week 4, 14 tests) + +- [ ] 1.34 `ShowObject_SameRootAndDescendant_DoesRefreshList` + `ShowObject_DifferentRoot_RecreatesAllSlices` +- [ ] 1.35 `ShowObject_SameRootDifferentDescendant_SetsCurrentSliceNew` +- [ ] 1.36 `ShowObject_NoOp_WhenAllParametersUnchanged` +- [ ] 1.37 `RefreshList_DoNotRefresh_DefersRefresh` + `RefreshList_CurrentSliceSurvivesRefresh` + `RefreshList_InvalidRoot_CallsReset` +- [ ] 1.38 `GetShowHiddenFieldsToolName_LexEntry_FallbackToLexiconEdit` (4 branches) +- [ ] 1.39 `SetCurrentSlicePropertyNames_ConstructsCorrectKeys` +- [ ] 1.40 `Initialize_SetsRequiredFields` + `Dispose_UnsubscribesNotifications` + `Dispose_CurrentSlice_GetsDeactivated` + `Dispose_DoubleDispose_IsNoOp` + `CheckDisposed_AfterDispose_Throws` + +### 1G. DataTree JumpToTool Tests (Week 5, 4 tests) + +- [ ] 1.41 `GetGuidForJumpToTool_ConcordanceTool_LexEntry` + `_LexSense` +- [ ] 1.42 `GetGuidForJumpToTool_LexiconEditTool` + `_ForEnableOnly_DoesNotCreate` + +### 1H. Slice Core & Lifecycle Tests (Week 5, 19 tests) + +- [ ] 1.43 `Abbreviation_AutoGeneratedFromLabel` + `Abbreviation_ExplicitOverridesAutoGeneration` +- [ ] 1.44 `IsSequenceNode_TrueForOwningSequence` + `IsCollectionNode_TrueForNonSequence` + `IsHeaderNode_ReadsXmlAttribute` +- [ ] 1.45 `ContainingDataTree_NullWhenOrphaned` + `ContainingDataTree_ReturnsParent` +- [ ] 1.46 `WrapsAtomic_ReadsConfigAttribute` + `CallerNodeEqual_StructuralComparison` +- [ ] 1.47 `Constructor_SetsVisibleFalse` + `Install_SetsHeightToMaxOfControlAndLabel` + `Install_NoLabel_FixesSplitterAtOnePixel` + `Install_SetsPanelMinSizeFromIndent` +- [ ] 1.48 `CheckDisposed_AfterDispose_Throws_Slice` + `Dispose_NullsAllFields` + `Dispose_UnsubscribesSplitterEvent` +- [ ] 1.49 `OnEnter_DuringConstruction_DoesNotSetCurrentSlice` + `BecomeReal_BaseReturnsSelf` + +### 1I. Slice Menu Command Tests (Week 5, 14 tests) + +- [ ] 1.50 `GetCanDeleteNow_RequiredAtomicField_ReturnsFalse` + `GetCanDeleteNow_VectorWithMultipleItems_ReturnsTrue` + `GetCanDeleteNow_VectorWithOneItem_Required_ReturnsFalse` +- [ ] 1.51 `GetCanMergeNow_SubsensesAlwaysMergeable` + `GetCanMergeNow_AllomorphsSameClass_Mergeable` + `GetCanMergeNow_NeedsTwoSameClassSiblings` +- [ ] 1.52 `GetCanSplitNow_SameClassOwner_ReturnsTrue` + `GetCanSplitNow_VectorLessThanTwo_ReturnsFalse` +- [ ] 1.53 `GetObjectForMenusToOperateOn_WrapsAtomic` + `GetObjectForMenusToOperateOn_VariantBackRef` +- [ ] 1.54 `GetSeqContext_WalksKeyBackward` + `GetSeqContext_GhostSlice_HandledCorrectly` +- [ ] 1.55 `StartsWith_BoxedIntEquality` + `HandleDeleteCommand_SelectsNearbySlice` + +### 1J. Slice Rendering, Child Gen, Field Config, XCore (Week 6, 31 tests) + +- [ ] 1.56 Rendering: `LabelIndent_Calculation` + `Expand_SetsStateToExpanded` + `Expand_PersistsExpansionState` + `Collapse_SetsStateToCollapsed` + `Collapse_PersistsCollapsedState` + `Collapse_RemovesDescendantSlices` + `ExpansionStateKey_NullForFixedSlices` + `IsObjectNode` tests +- [ ] 1.57 Child Gen: `GenerateChildren_NothingResult_SetsFixed` + `_PossibleResult_SetsCollapsedEmpty` + `_PersistentExpansion_RestoresExpanded` + `ExtraIndent` tests +- [ ] 1.58 Field Config: `SetFieldVisibility_PersistsOverride` + `_SameValue_NoOp` + `GetSibling_Up_SkipsChildren` + `_Down_SkipsChildren` + `_AtBoundary_ReturnsNull` +- [ ] 1.59 Field Config: `HelpTopicResolution_FourLevelFallback` + `_CrossRefVsLexicalRelation` + `ReplacePartWithNewAttribute_PropagatesAcrossSlices` +- [ ] 1.60 XCoreColleague: `GetMessageTargets_VisibleWithColleagueControl` + `_Hidden_ReturnsEmpty` + `Init_SetsMediator_InitsColleagueControl` + `SetCurrentState_PropagatesActiveToParents` + `_False_DeactivatesChain` +- [ ] 1.61 Splitter: `SetSplitPosition_UsesBaseAndIndent` + `OnSizeChanged_PreservesScrollPosition` + `SetWidthForDataTreeLayout_MarksFlag` + +### 1K. SliceFactory Extensions (Week 6, 4 tests) + +- [ ] 1.62 `Create_MultistringEditor_ReturnsMultiStringSlice` + `Create_StringEditor_ReturnsStringSlice` +- [ ] 1.63 `Create_UnknownEditor_ReflectionFallback` + `Create_NullEditor_ReturnsBasicSlice` + +### 1L. Final Verification + +- [ ] 1.64 Run `.\test.ps1` and verify all 139 new + ~25 existing tests pass +- [ ] 1.65 Review test coverage against test-plan-datatree.md and test-plan-slice.md — confirm no gaps + +## 2. Phase 1: Partial-Class File Split + +- [ ] 2.1 Split `Src/Common/Controls/DetailControls/DataTree.cs` into `DataTree.cs` (core: data members, constructor, `Initialize`, `Dispose`, core properties) — ~500 lines +- [ ] 2.2 Create `DataTree.SliceManagement.cs` — move slice collection methods (`InsertSlice`, `RemoveSlice`, `RawSetSlice`, `InstallSlice`, `ForceSliceIndex`, `ResetTabIndices`, `InsertSliceRange`, tooltip management) — ~280 lines +- [ ] 2.3 Create `DataTree.LayoutParsing.cs` — move XML layout methods (`CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode`, `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice`, `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight helpers) — ~1,000 lines +- [ ] 2.4 Create `DataTree.WinFormsLayout.cs` — move WinForms layout/paint methods (`OnLayout`, `HandleLayout1`, `MakeSliceRealAt`, `MakeSliceVisible`, `OnPaint`, `HandlePaintLinesBetweenSlices`, `OnSizeChanged`, hit-test, `FieldAt`, `FieldOrDummyAt`, `AboutToCreateField`) — ~400 lines +- [ ] 2.5 Create `DataTree.Navigation.cs` — move focus/navigation methods (`CurrentSlice` property, `DescendantForSlice`, `Goto*` methods, `FocusFirstPossibleSlice`, `SelectFirstPossibleSlice`, `ScrollCurrentAndIfPossibleSectionIntoView`, `SetDefaultCurrentSlice`, `ActiveControl`) — ~250 lines +- [ ] 2.6 Create `DataTree.Messaging.cs` — move `IxCoreColleague` implementation and all `On*` message handlers — ~650 lines +- [ ] 2.7 Create `DataTree.Persistence.cs` — move `ShowObject`, `RefreshList`, `CreateSlices`, show-hidden logic, `PrepareToGoAway`, `PersistPreferences`, `RestorePreferences`, `SetCurrentSlicePropertyNames` — ~350 lines +- [ ] 2.8 Split `Src/Common/Controls/DetailControls/Slice.cs` into partial-class files: `Slice.cs` (core properties), `Slice.Lifecycle.cs` (Install, Dispose, BecomeReal), `Slice.TreeNode.cs` (SliceTreeNode rendering), `Slice.Children.cs` (GenerateChildren, CreateIndentedNodes) +- [ ] 2.9 Verify `.csproj` auto-includes new files (SDK-style), run `.\build.ps1`, run `.\test.ps1` + +## 3. Phase 2: Extract Collaborators + +- [ ] 3.1 Create `SliceLayoutBuilder` class in `Src/Common/Controls/DetailControls/SliceLayoutBuilder.cs` — constructor takes `LcmCache`, `Inventory` (layouts), `Inventory` (parts), `IFwMetaDataCache`, `SliceFilter` +- [ ] 3.2 Move `CreateSlicesFor`, `ApplyLayout`, `ProcessPartRefNode`, `ProcessPartChildren`, `ProcessSubpartNode` from `DataTree.LayoutParsing.cs` to `SliceLayoutBuilder` — replace `this` references with injected dependencies +- [ ] 3.3 Move `AddSimpleNode`, `AddAtomicNode`, `AddSeqNode`, `MakeGhostSlice` to `SliceLayoutBuilder` +- [ ] 3.4 Move `EnsureCustomFields`, `GetMatchingSlice`, `GetFlidFromNode`, label/weight resolution methods to `SliceLayoutBuilder` +- [ ] 3.5 Update `DataTree` to hold a `SliceLayoutBuilder` instance and delegate all XML-parsing calls to it +- [ ] 3.6 Add unit tests for `SliceLayoutBuilder` in `DetailControlsTests/SliceLayoutBuilderTests.cs` — test `CreateSlicesFor` with mock inventories, no `Form` required +- [ ] 3.7 Create `ShowHiddenFieldsManager` class in `Src/Common/Controls/DetailControls/ShowHiddenFieldsManager.cs` — extract `GetShowHiddenFieldsToolName`, key-resolution logic, `HandleShowHiddenFields` +- [ ] 3.8 Update `DataTree` to delegate show-hidden logic to `ShowHiddenFieldsManager` +- [ ] 3.9 Add unit tests for `ShowHiddenFieldsManager` — test key resolution for LexEntry, non-LexEntry, missing `currentContentControl` +- [ ] 3.10 Create `DataTreeNavigator` class in `Src/Common/Controls/DetailControls/DataTreeNavigator.cs` — extract `CurrentSlice` state management, `Goto*` methods, `FocusFirstPossibleSlice` +- [ ] 3.11 Update `DataTree` to delegate navigation to `DataTreeNavigator` +- [ ] 3.12 Run `.\build.ps1`, run `.\test.ps1`, verify all characterization tests pass + +## 4. Phase 3: Model/View Separation + +- [ ] 4.1 Define `SliceSpec` data class in `Src/Common/Controls/DetailControls/SliceSpec.cs` — properties: `Label`, `Abbreviation`, `Indent`, `EditorType`, `ConfigNode` (XmlNode), `FieldId`, `Object` (ICmObject), `Visibility`, `Weight`, `Tooltip`, `Key` (object[]) +- [ ] 4.2 Define `IDataTreeView` interface in `Src/Common/Controls/DetailControls/IDataTreeView.cs` — methods: `MaterializeSlices(IReadOnlyList)`, `GetCurrentSliceIndex()`, `SetCurrentSlice(int)`, `Refresh()` +- [ ] 4.3 Create `DataTreeModel` class in `Src/Common/Controls/DetailControls/DataTreeModel.cs` — constructor takes `LcmCache`, `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `PropertyTable` +- [ ] 4.4 Add `DataTreeModel.BuildSliceSpecs(ICmObject root, string layoutName, string layoutChoiceField)` — produces `List` by calling `SliceLayoutBuilder` (modify builder to return specs instead of creating Slice controls) +- [ ] 4.5 Add virtual `DataTreeModel.ResolveRootObject(ICmObject) → ICmObject` for `StTextDataTree` override hook +- [ ] 4.6 Modify `SliceLayoutBuilder` to produce `SliceSpec` list instead of directly calling `SliceFactory.Create` — this is the core boundary change +- [ ] 4.7 Update `DataTree` to implement `IDataTreeView` — receive `SliceSpec[]` from model, call `SliceFactory.Create(spec)` to materialize, manage `ObjSeqHashMap` for reuse +- [ ] 4.8 Update `DataTree.ShowObject` to delegate to `DataTreeModel.BuildSliceSpecs` then `MaterializeSlices` +- [ ] 4.9 Adapt `StTextDataTree` in `Src/LexText/Interlinear/InfoPane.cs` to override model-layer `ResolveRootObject` instead of view-layer `ShowObject` +- [ ] 4.10 Verify `DataTreeModel` has no reference to `System.Windows.Forms` — check `.csproj` references and `using` statements +- [ ] 4.11 Add unit tests for `DataTreeModel.BuildSliceSpecs` — verify spec list matches expected layout, no WinForms infrastructure needed +- [ ] 4.12 Run `.\build.ps1`, run `.\test.ps1`, verify all tests pass +- [ ] 4.13 Manual smoke test: open FieldWorks, navigate Lexicon Edit entry display, verify all slices render correctly, toggle Show Hidden Fields, navigate between entries + +## 5. Documentation + +- [ ] 5.1 Update `Src/Common/Controls/DetailControls/AGENTS.md` — document new classes (`DataTreeModel`, `SliceLayoutBuilder`, `ShowHiddenFieldsManager`, `SliceSpec`, `IDataTreeView`) and the model/view pattern +- [ ] 5.2 Update `openspec/specs/architecture/ui-framework/winforms-patterns.md` — document DataTree model/view composition pattern +- [ ] 5.3 Update `openspec/specs/architecture/layers/layer-model.md` — add detail-tree model sublayer diff --git a/openspec/changes/fieldworks-avalonia-shell-migration/.openspec.yaml b/openspec/changes/fieldworks-avalonia-shell-migration/.openspec.yaml new file mode 100644 index 0000000000..93831bd262 --- /dev/null +++ b/openspec/changes/fieldworks-avalonia-shell-migration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/fieldworks-avalonia-shell-migration/design.md b/openspec/changes/fieldworks-avalonia-shell-migration/design.md new file mode 100644 index 0000000000..081ff902be --- /dev/null +++ b/openspec/changes/fieldworks-avalonia-shell-migration/design.md @@ -0,0 +1,89 @@ +## Context + +FieldWorks currently starts as a WinForms/xWorks application. Startup, project selection, main-window construction, command routing, property-table state, menus, toolbars, sidebars, status panes, dialogs, and dynamic content hosting are tied to WinForms/XCore concepts. Lexical Edit Avalonia work creates a migrated region, but a fully Avalonia product requires a later shell/windowing migration that replaces the default application host and all main screens without preserving WinForms as the hidden runtime shell. + +The architecture review identified several constraints: + +- `FwApp`, `FieldWorksManager`, xWorks windows, and many dialogs assume `System.Windows.Forms.Form` ownership. +- XCore mediator/property-table behavior is both command bus and UI composition state. +- Shell composition is driven by XML configuration such as `Main.xml`, including commands, lists, menus, sidebars, toolbar items, listeners, defaults, extension includes, and localization metadata. +- Main screens outside Lexical Edit may still depend on WinForms controls, XMLViews, RootSite/native Views, Gecko, or native rendering. +- Avalonia brings different application lifetime, dispatcher, command, focus, validation, accessibility, styling, and window ownership patterns. +- Retained custom linguistics services can stay only as non-UI service dependencies. + +## Goals / Non-Goals + +**Goals:** + +- Make Avalonia the default FieldWorks application lifetime, shell, windowing system, and main-screen host. +- Preserve command IDs, shortcuts, menus, navigation semantics, status/progress behavior, localization, extension hooks, project startup behavior, and multi-window behavior. +- Keep XCore/Mediator compatibility during migration while moving UI composition to typed shell definitions and Avalonia services. +- Host migrated screens through registered Avalonia views and explicit view models/presenters. +- Keep retained native/external services outside the Avalonia display/layout/input boundary. + +**Non-Goals:** + +- No LCModel rewrite or storage schema change. +- No requirement that every feature surface migrate in the first shell skeleton. +- No permanent WinForms or native UI island in the final default app. +- No Graphite or Gecko Graphite default-path compatibility layer. + +## Decisions + +### 1. Phase two depends on regional Avalonia readiness + +The shell migration starts after Lexical Edit has proven the critical regional patterns: typed view definitions, parity automation, Graphite-free default path, edit sessions, and no native viewing/rendering inside migrated regions. The shell must not become a container for unresolved native Views or Graphite dependencies. + +### 2. Application lifetime and windowing use framework-neutral ports first + +Introduce interfaces for desktop lifetime, main window, active-window registry, dialog owner, UI dispatcher, shutdown, and modal state while WinForms remains default. Avalonia then implements those ports using classic desktop lifetime and explicit window ownership. + +### 3. Shell XML becomes typed shell definition during transition + +Existing XCore shell XML remains a migration input, not the final runtime composition model. A typed shell definition captures commands, menus, toolbars, sidebars, status panes, areas/tools, includes, listeners, shortcuts, icons, defaults, and localization metadata with diagnostics for unsupported constructs. + +### 4. Command routing is bridged before it is replaced + +XCore mediator/property-table remains a compatibility command and state bridge during migration. Avalonia commands expose stable IDs, labels, gestures, icons, visibility, enabled state, target resolution, and diagnostics independent of WinForms menu/toolstrip adapters. + +### 5. Main screens migrate by registry and manifest + +An Avalonia screen registry maps area/tool IDs to presenters and views. Each migrated screen has a manifest covering entry points, shell commands, state, accessibility, performance, legacy adapters, native boundary status, and rollback/default-switch behavior. + +### 6. Shell services are testable outside the full app + +Navigation, commands, dialog ownership, status/progress, settings persistence, accessibility metadata, and shell composition are tested through pure services, typed snapshots, and Avalonia.Headless before full-app smoke tests. + +### 7. The shell phase consumes earlier seam capabilities instead of redefining them + +The shell phase consumes the previously chosen seam capabilities from `lexical-edit-avalonia-migration` rather than reopening those decisions by default. In particular, `avalonia-command-focus` is promoted from screen-local usage to shell-global command and target routing here, while `avalonia-ui-scheduler` and `avalonia-lifetime` are promoted from local editor seams to application-wide services. If those choices later prove wrong, the pivot triggers in `lexical-edit-avalonia-migration/seam-recommendations.md` govern when to change direction. + +## Risks / Trade-offs + +- Runtime split between .NET Framework managed code and newer Avalonia projects -> Resolve host/runtime strategy early and avoid hidden cross-runtime assumptions. +- XCore extension behavior may depend on WinForms adapters -> Preserve command IDs and add typed diagnostics for unsupported UI constructs. +- WinForms dialog ownership is widespread -> Introduce dialog-owner contracts and migrate high-frequency dialogs before default switch. +- Main screens outside Lexical Edit may still use native Views or Gecko -> Keep explicit legacy boundaries and block default-path completion until each screen manifest passes. +- UI automation can become flaky -> Push deep behavior into service/snapshot tests and reserve UI automation for shell smoke, accessibility, and platform behavior. +- Browser/PDF/print scope may exceed shell work -> Treat browser/PDF as replaceable global services with their own decision gates. + +## Migration Plan + +1. Confirm Lexical Edit regional gates or track unresolved blockers explicitly. +2. Inventory current shell entry points, XML composition, command IDs, dialogs, main screens, startup/shutdown paths, native/WinForms dependencies, and browser/PDF paths. +3. Extract framework-neutral shell/lifetime/dialog/dispatcher/command/navigation/status/settings/accessibility ports while WinForms remains default, consuming `avalonia-ui-scheduler` and `avalonia-lifetime` rather than redefining them. +4. Build typed shell-definition importer and snapshot tests for `Main.xml` and area/tool includes. +5. Build an Avalonia shell preview path with sample data and migrated regions. +6. Bridge XCore commands and property state into typed Avalonia command/state services, consuming the shell-global phase of `avalonia-command-focus`. +7. Implement Avalonia navigation, content host, menus, context menus, toolbars, side panes, status/progress, and dialog service. +8. Migrate main screens area by area using screen manifests and legacy-host boundaries only for non-migrated screens. +9. Add startup/shutdown, installer/runtime, accessibility, localization, performance, and full-app smoke gates. +10. Make Avalonia shell default only after hard gates pass; then retire WinForms shell and default-path adapters. + +## Open Questions + +1. What runtime target is required for the final application host, and what bridge is needed for remaining .NET Framework projects? +2. Which shell XML extension points must remain supported for partner or add-on workflows? +3. Which docking/layout behavior requires owned controls versus a third-party Avalonia docking library? +4. Which browser/PDF engine replaces Gecko-backed preview, print, and PDF workflows? +5. Which main screens outside Lexical Edit block the first Avalonia shell default switch? \ No newline at end of file diff --git a/openspec/changes/fieldworks-avalonia-shell-migration/proposal.md b/openspec/changes/fieldworks-avalonia-shell-migration/proposal.md new file mode 100644 index 0000000000..75f2fb510b --- /dev/null +++ b/openspec/changes/fieldworks-avalonia-shell-migration/proposal.md @@ -0,0 +1,46 @@ +## Why + +The Lexical Edit Avalonia migration proves the first high-risk regional replacement, but FieldWorks will still start and compose its application through a WinForms/XCore shell. A second phase is needed to replace application lifetime, windowing, navigation, menus, dialogs, and main-screen hosting with Avalonia so the final default product is a fully Avalonia app rather than an Avalonia island inside the old shell. + +## What Changes + +- Add an Avalonia desktop shell using explicit application lifetime, main-window ownership, active-window tracking, dialog ownership, and shutdown services. +- Extract framework-neutral shell/window contracts so `FwApp`, `FieldWorksManager`, xWorks windows, project startup, multi-window behavior, and modal ownership no longer require `System.Windows.Forms.Form` in the default path. +- Compile/import existing shell configuration such as `Language Explorer/Configuration/Main.xml` into typed shell definitions for commands, lists, menus, context menus, sidebars, toolbars, status panes, listeners, extension includes, shortcuts, localization metadata, and screen/tool registrations. +- Introduce Avalonia shell composition for navigation, content hosting, record/side panes, menus, context menus, toolbars, status/progress, diagnostics, accessibility, and theme resources. +- Bridge XCore mediator/property-table behavior into typed Avalonia commands and state services during migration, then retire WinForms UI adapters from the default path. +- Add an Avalonia main-screen registry and migrate screens area by area after Lexical Edit gates are proven. +- Move global dialogs and services behind abstractions: project open/create/backup/restore, writing systems, settings, import/export, find/replace, styles, help, feedback, progress, keyboarding, clipboard, browser/PDF, print, and accessibility. +- Retire WinForms shell, WinForms dynamic content host, WinForms-only default dialogs, FlexUIAdapter default behavior, Gecko Graphite assumptions, and native viewing/rendering from the final default app. + +## Non-goals + +- No LCModel rewrite or project data schema change. +- No one-shot migration of every screen before shell seams and parity gates exist. +- No permanent WinForms embedding in the final default app. +- No removal of native/external linguistics services such as XAmple, spelling, ICU, Encoding Converters, or parser tools when isolated behind non-UI service contracts. +- No Graphite or Gecko Graphite compatibility path in the final Avalonia default UI. + +## Capabilities + +### New Capability + +- `fieldworks-avalonia-shell-migration`: Avalonia default shell, typed shell composition, application/window lifetime, command bridge, main-screen registry, validation gates, packaging, and final WinForms shell decommissioning. + +### Architecture Areas Covered + +- `architecture/layers/entry-points`: Avalonia host and dual-lifetime transition. +- `architecture/ui-framework/xcore-mediator`: Mediator compatibility bridge and final composition boundary. +- `architecture/ui-framework/winforms-patterns`: WinForms shell decommissioning gates and temporary adapter rules. +- `architecture/testing/test-strategy`: Shell contract snapshots, Avalonia.Headless shell tests, UIA baselines, and full-app smoke gates. +- `architecture/interop/native-boundary`: Retained native services outside Avalonia UI boundaries; no native viewing/rendering in the default shell. +- `architecture/build-deploy/installer`: Avalonia runtime/package harvest and WinForms/Gecko retirement gates. +- `architecture/build-deploy/localization`: Shell labels, shortcuts, tooltips, status text, and dialogs preserved through typed shell migration. + +## Impact + +- Managed shell/framework code: `Src/Common/FieldWorks/`, `Src/Common/Framework/`, `Src/XCore/`, `Src/xWorks/`, and main FLEx screens under `Src/LexText/`. +- Avalonia code: `Src/Common/FwAvalonia/`, `Src/Common/FwAvaloniaPreviewHost/`, and future FieldWorks Avalonia shell projects. +- Configuration/localization: `DistFiles/Language Explorer/Configuration/Main.xml`, area/tool XML includes, existing localization resources, and Crowdin integration. +- Native/interop: no new native UI surface; retained native/external services remain behind service boundaries; native Views/Graphite/Gecko Graphite are excluded from the default Avalonia UI. +- Build/deploy: traversal build, solution integration, installer/runtime packaging, app startup path, and dependency harvest. \ No newline at end of file diff --git a/openspec/changes/fieldworks-avalonia-shell-migration/specs/fieldworks-avalonia-shell-migration/spec.md b/openspec/changes/fieldworks-avalonia-shell-migration/specs/fieldworks-avalonia-shell-migration/spec.md new file mode 100644 index 0000000000..5047a41b16 --- /dev/null +++ b/openspec/changes/fieldworks-avalonia-shell-migration/specs/fieldworks-avalonia-shell-migration/spec.md @@ -0,0 +1,180 @@ +## ADDED Requirements + +### Requirement: FieldWorks provides an Avalonia default shell + +FieldWorks SHALL provide an Avalonia desktop shell as the final default application shell for migrated workflows. + +#### Scenario: Avalonia shell owns default chrome +- **WHEN** FieldWorks runs in the final default mode +- **THEN** the top-level shell, navigation, content host, menus, toolbars, status panes, dialogs, and application chrome SHALL be Avalonia-owned +- **AND** WinForms shell controls SHALL NOT be created for the default path + +### Requirement: Shell preserves FieldWorks workflow semantics + +The Avalonia shell SHALL preserve project startup, area/tool navigation, command IDs, shortcuts, menu semantics, status/progress behavior, localization, accessibility, and multi-window behavior from the legacy shell. + +#### Scenario: Project opens to equivalent workspace +- **WHEN** a user opens a project in the Avalonia shell +- **THEN** the shell SHALL initialize project context, area/tool selection, command state, status panes, and main content equivalent to the legacy baseline for covered workflows + +### Requirement: Shell XML imports into typed shell definition + +Existing shell configuration XML SHALL import into a typed shell definition during migration. + +#### Scenario: Main XML imports deterministically +- **WHEN** `Language Explorer/Configuration/Main.xml` and area/tool includes are imported +- **THEN** the typed shell definition SHALL include commands, lists, areas, tools, menus, context menus, sidebars, toolbars, status panes, listeners, defaults, extension includes, shortcuts, icons, and localization metadata + +### Requirement: Unsupported shell constructs are diagnostic + +Unsupported shell XML constructs SHALL produce deterministic diagnostics instead of silent omission. + +#### Scenario: Unsupported dynamic loader is reported +- **WHEN** shell import encounters a dynamic loader, listener, toolbar widget, or status panel that has no Avalonia equivalent +- **THEN** the importer SHALL report the XML path, command/tool identifier when available, migration severity, and required follow-up + +### Requirement: Typed shell definition is the runtime target + +The final Avalonia shell SHALL use typed shell definitions as its runtime composition contract, with XML retained only for migration/import/audit scenarios. + +#### Scenario: Runtime composition avoids raw XML +- **WHEN** a shell area has passed migration gates +- **THEN** the Avalonia shell SHALL compose commands, navigation, menus, toolbars, panes, and status regions from the typed definition rather than parsing runtime XML + +### Requirement: Windowing uses framework-neutral lifetime services + +Application startup, shutdown, active-window tracking, modal ownership, and UI dispatch SHALL use framework-neutral interfaces during migration. + +#### Scenario: WinForms and Avalonia lifetimes share contract +- **WHEN** shell lifetime behavior is tested +- **THEN** both WinForms compatibility and Avalonia implementations SHALL satisfy the same app lifetime, active-window, dialog-owner, dispatcher, and shutdown contracts + +### Requirement: Avalonia implementation owns final desktop lifetime + +The final default shell SHALL use Avalonia desktop lifetime and explicit top-level window ownership. + +#### Scenario: Default startup creates Avalonia main window +- **WHEN** FieldWorks starts in final default mode +- **THEN** the app SHALL create Avalonia top-level windows through the shell lifetime service +- **AND** it SHALL NOT require WinForms `Application.Run` or `Form` ownership for default shell windows + +### Requirement: Shutdown and disposal are deterministic + +The shell SHALL deterministically dispose windows, dialogs, project services, cache-bound services, background tasks, and retained native service handles. + +#### Scenario: Project shutdown releases shell resources +- **WHEN** a project window closes or the application exits +- **THEN** shell tests SHALL prove windows, dialogs, event subscriptions, cache-bound services, and background tasks are released or canceled + +### Requirement: Commands are represented by typed descriptors + +Shell commands SHALL expose stable IDs, labels, localized resources, gestures, icons, visibility, enabled state, checked state when applicable, execution targets, and diagnostics independent of WinForms menu/toolstrip adapters. + +#### Scenario: Command descriptor drives menu item +- **WHEN** a typed command appears in an Avalonia menu or toolbar +- **THEN** its label, icon, shortcut, enabled state, visibility, automation metadata, and handler target SHALL come from the typed command model + +### Requirement: XCore mediator bridges during migration + +XCore mediator and property-table behavior MAY remain as compatibility command/state infrastructure during migration, but SHALL NOT own final Avalonia UI composition. + +#### Scenario: Mediator handler executes through Avalonia command +- **WHEN** a migrated menu item invokes a legacy XCore command handler +- **THEN** the command SHALL route through an explicit mediator bridge +- **AND** the bridge SHALL expose diagnostics for target resolution and command state + +### Requirement: Command parity is validated + +The shell migration SHALL validate command availability, shortcuts, context menus, command enablement, one-at-a-time behavior, and target resolution against legacy baselines. + +#### Scenario: Shortcut parity is verified +- **WHEN** a legacy shortcut is migrated to Avalonia +- **THEN** automated or semantic tests SHALL prove the shortcut reaches the same command target and state behavior for covered workflows + +### Requirement: Main screens register through typed screen registry + +Main screens SHALL register through a typed Avalonia screen registry keyed by stable area/tool identifiers. + +#### Scenario: Area tool resolves to registered screen +- **WHEN** the user selects a migrated area/tool +- **THEN** the shell SHALL resolve the screen through the registry and create the registered presenter/view rather than a WinForms dynamic content host + +### Requirement: Each migrated screen has a manifest + +Each migrated main screen SHALL have a manifest describing commands, state, content host, shell services, legacy adapters, native-boundary status, accessibility IDs, performance budgets, rollback behavior, and default-switch gates. + +#### Scenario: Screen completion requires manifest evidence +- **WHEN** a screen is proposed for Avalonia completion +- **THEN** its manifest SHALL identify passing evidence for command routing, navigation, accessibility, localization, native-boundary status, Graphite-free behavior when relevant, and performance budgets + +### Requirement: Legacy content is explicit and temporary + +Non-migrated screens MAY be hosted through explicit legacy boundaries during transition, but the final default app SHALL NOT permanently embed WinForms or native viewing UI. + +#### Scenario: Legacy island is tracked +- **WHEN** a non-migrated screen is hosted inside the Avalonia shell during transition +- **THEN** the screen SHALL have a manifest identifying why it remains legacy, which commands it supports, and what gates remove the legacy host + +### Requirement: Entry points support Avalonia host transition + +FieldWorks entry points SHALL support a transition from WinForms application lifetime to Avalonia application lifetime without bypassing project startup, diagnostics, cache initialization, or command registration requirements. + +#### Scenario: Avalonia host follows canonical startup +- **WHEN** the Avalonia shell startup path is enabled +- **THEN** startup SHALL still initialize diagnostics, project selection/opening, LCModel cache, service registration, command infrastructure, and safe shutdown hooks through documented entry points + +### Requirement: Hidden WinForms startup is disallowed in final mode + +Final default startup SHALL NOT secretly create WinForms shell windows or run WinForms application lifetime to host Avalonia content. + +#### Scenario: Startup audit detects WinForms shell dependency +- **WHEN** default-startup validation runs +- **THEN** it SHALL fail if the default path creates the retired WinForms shell, dynamic content host, or WinForms-only main-window services + +### Requirement: Final shell excludes native UI boundaries + +The final Avalonia shell SHALL NOT use native Views, Graphite, Gecko Graphite rendering, or other native viewing/rendering/editor infrastructure for default UI composition. + +#### Scenario: Native UI dependency fails default audit +- **WHEN** default-shell dependency validation runs +- **THEN** it SHALL fail if default shell, chrome, navigation, dialogs, or migrated screens instantiate native viewing/rendering/editor infrastructure + +### Requirement: Retained native services stay outside UI composition + +Native or external linguistics services SHALL remain outside Avalonia UI composition when exposed through explicit non-UI service contracts. + +#### Scenario: Linguistics service is allowed +- **WHEN** the Avalonia shell or migrated screens invoke XAmple, spelling, parser tools, ICU, Encoding Converters, or similar services +- **THEN** those services SHALL remain outside Avalonia display, layout, hit testing, focus, selection, and editor realization responsibilities + +### Requirement: Shell migration uses layered validation + +The shell migration SHALL use shell-definition snapshots, command tests, WinForms UIA baselines, Avalonia.Headless tests, integration tests, semantic UI snapshots, full-app smoke tests, and dependency audits. + +#### Scenario: Shell behavior is frozen before replacement +- **WHEN** a shell subsystem is replaced +- **THEN** the migration SHALL identify existing baseline evidence or add shell contract, semantic, UIA, or integration tests for that behavior + +### Requirement: Full-app smoke gates protect default switch + +Before Avalonia shell becomes default, full-app smoke tests SHALL launch the app, open or create a project, switch representative areas/tools, execute representative commands, show dialogs, close windows, and shut down cleanly. + +#### Scenario: Default switch waits for smoke gates +- **WHEN** Avalonia shell is proposed as default +- **THEN** default-switch validation SHALL include full-app smoke evidence, accessibility evidence, localization evidence, performance evidence, and dependency audit evidence + +### Requirement: Installer packages Avalonia shell runtime + +Installer and packaging logic SHALL include the Avalonia shell runtime assets required by the default application host. + +#### Scenario: Avalonia shell artifacts are harvested +- **WHEN** installer packaging runs for a build where Avalonia shell is default +- **THEN** required Avalonia assemblies, native dependencies, resources, configuration, and generated shell definitions SHALL be included + +### Requirement: Shell localization survives typed migration + +Typed shell definitions SHALL preserve localizable labels, tooltips, menu text, command text, status text, dialog text, shortcut descriptions, and resource identifiers from existing shell configuration and resources. + +#### Scenario: Imported command keeps localization identity +- **WHEN** shell XML or resources define a localizable command label or tooltip +- **THEN** the typed shell definition SHALL retain localization identity so Crowdin/resource workflows can update the Avalonia shell text diff --git a/openspec/changes/fieldworks-avalonia-shell-migration/tasks.md b/openspec/changes/fieldworks-avalonia-shell-migration/tasks.md new file mode 100644 index 0000000000..4b1366bb80 --- /dev/null +++ b/openspec/changes/fieldworks-avalonia-shell-migration/tasks.md @@ -0,0 +1,77 @@ +## 1. Prerequisites and Inventory + +- [ ] 1.1 Confirm Lexical Edit Avalonia migration gates are complete or explicitly tracked as blockers. +- [ ] 1.2 Inventory shell entry points in `Src/Common/FieldWorks/`, `Src/Common/Framework/`, `Src/XCore/`, and `Src/xWorks/`. +- [ ] 1.3 Inventory `Main.xml`, area/tool XML, command IDs, menus, toolbars, sidebars, status panes, listeners, defaults, includes, and localization metadata. +- [ ] 1.4 Inventory remaining default-path WinForms, RootSite/native Views, XMLViews, Gecko, Graphite, browser/PDF, and dialog dependencies by main screen. +- [ ] 1.5 Define hard gates for a completely Avalonia default app: no default WinForms shell, no default native viewing/rendering, no Graphite, no Gecko Graphite, passing accessibility/localization/performance/smoke evidence. + +## 2. Shell Contracts + +- [ ] 2.1 Extract framework-neutral managed interfaces for app lifetime, main window, active-window registry, dialog owner, modal state, UI dispatcher, shutdown, progress, settings, and status services, following `avalonia-ui-scheduler` and `avalonia-lifetime`. +- [ ] 2.2 Add compatibility adapters for current WinForms `FwApp`, `FieldWorksManager`, xWorks window, and dialog-owner behavior. +- [ ] 2.3 Remove direct `Form`/`Control` requirements from new shell-facing contracts before Avalonia shell construction begins. +- [ ] 2.4 Add contract tests for startup, active-window tracking, dialog ownership, shutdown, and UI dispatch behavior. + +## 3. Typed Shell Composition + +- [ ] 3.1 Build typed shell-definition importer for `Main.xml`, area/tool XML, includes, extension hooks, resources, listeners, and default properties. +- [ ] 3.2 Represent commands, lists, areas/tools, menus, context menus, toolbars, status panes, shortcuts, icons, localization metadata, and screen registrations. +- [ ] 3.3 Add diagnostics for unsupported commands, listeners, dynamic loaders, toolbar widgets, status panels, and extension constructs. +- [ ] 3.4 Add deterministic shell-definition snapshot tests. + +## 4. Command Routing and State + +- [ ] 4.1 Define typed command descriptors with stable IDs, labels, gestures, icons, visibility, enabled state, target resolution, and diagnostics, following `avalonia-command-focus`. +- [ ] 4.2 Bridge XCore mediator handlers and property-table state into Avalonia commands and active-target routing, following `avalonia-command-focus`. +- [ ] 4.3 Add tests for command enable/visible state, shortcuts, one-at-a-time commands, command target selection, and mediator bridge behavior. +- [ ] 4.4 Add menu/context-menu automation metadata and localization checks. + +## 5. Avalonia Shell Skeleton + +- [ ] 5.1 Create Avalonia shell project and integrate it into `FieldWorks.sln` and `FieldWorks.proj` traversal only after runtime strategy is approved. +- [ ] 5.2 Implement main window, app lifetime, navigation regions, content host, status/progress region, diagnostics hooks, theme resources, and accessibility root metadata. +- [ ] 5.3 Run the shell in preview/sample mode before LCModel project startup. +- [ ] 5.4 Add Avalonia.Headless tests for shell creation, navigation host swapping, command dispatch, status updates, dialog ownership, focus traversal, and pane state. + +## 6. Navigation and Screen Registry + +- [ ] 6.1 Map area/tool IDs from typed shell definition to an Avalonia screen registry. +- [ ] 6.2 Implement area/tool navigation and persisted `areaChoice`/`currentContentControl` compatibility. +- [ ] 6.3 Add screen manifests for each migrated main screen, including commands, state, native-boundary status, accessibility, performance, rollback, and default-switch gates. +- [ ] 6.4 Add memory-project and sample-project navigation tests. + +## 7. Menus, Toolbars, Status, and Layout + +- [ ] 7.1 Render menu and context-menu structures with labels, shortcuts, icons, separators, extension items, visibility, and enablement. +- [ ] 7.2 Render standard/format/insert/view toolbars, including writing-system and style selectors. +- [ ] 7.3 Render status panels for message, progress, area, sort, filter, parsing, and record number. +- [ ] 7.4 Implement split panes, side panes, record-list region, content panes, collapse/restore behavior, and layout persistence. +- [ ] 7.5 Evaluate a docking library only if owned Avalonia controls cannot meet documented FieldWorks workflows. + +## 8. Dialogs and Global Services + +- [ ] 8.1 Introduce dialog service for project, writing-system, settings, import/export, find/replace, styles, help, feedback, and utility dialogs. +- [ ] 8.2 Migrate high-frequency dialogs first and retain explicit legacy adapters only while blocked. +- [ ] 8.3 Add owner/modal, cancellation, focus return, accessibility, and localization tests for migrated dialogs, following `avalonia-command-focus` and `avalonia-lifetime`. +- [ ] 8.4 Isolate browser/PDF/print behind replaceable services and select a non-Graphite default strategy. + +## 9. Main Screen Migration + +- [ ] 9.1 Migrate Lexicon screens after Lexical Edit gates. +- [ ] 9.2 Migrate Words/Interlinear screens. +- [ ] 9.3 Migrate Grammar/Morphology screens. +- [ ] 9.4 Migrate Notebook screens. +- [ ] 9.5 Migrate Lists screens. +- [ ] 9.6 Migrate dictionary preview/export, print, browser/PDF-dependent workflows, or isolate them outside the default path until replaced. + +## 10. Startup, Shutdown, Installer, and Default Switch + +- [ ] 10.1 Add Avalonia app startup path with project selection, cache creation, splash/safe-mode behavior, remote request listener, no-UI/app-server modes, and update checks accounted for. +- [ ] 10.2 Add shutdown/disposal tests for windows, caches, dialogs, background services, and retained native services. +- [ ] 10.3 Update installer/runtime packaging and dependency harvest for Avalonia shell assets. +- [ ] 10.4 Add feature flag/default selector for Avalonia shell. +- [ ] 10.5 Run full local build/test and app smoke gates before default switch. +- [ ] 10.6 Make Avalonia shell default only after hard gates pass. +- [ ] 10.7 Remove WinForms shell default path, FlexUIAdapter default dependency, WinForms dynamic content host, retired dialogs, and obsolete shell XML runtime pieces. +- [ ] 10.8 Revisit heavier reactive or region-framework alternatives only if the pivot triggers recorded in `lexical-edit-avalonia-migration/seam-recommendations.md` are met. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/.openspec.yaml b/openspec/changes/lexical-edit-avalonia-migration/.openspec.yaml new file mode 100644 index 0000000000..93831bd262 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-13 diff --git a/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md new file mode 100644 index 0000000000..e8ee325d75 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md @@ -0,0 +1,370 @@ +# Lexical Edit Avalonia Migration Architecture Diagrams + +These diagrams summarize the current WinForms architecture, the migration seams, the testing strategy, the first optional Avalonia slices, the table/full Lexical Edit path, and the final default architecture after Graphite and native viewing/rendering are removed from migrated regions. + +Legend used across diagrams: + +- Red dashed nodes are decommissioning targets for completed Avalonia regions. +- Green nodes are Avalonia or future managed UI pieces. +- Blue nodes are dependency-inverted service contracts. +- Yellow nodes are validation and test layers. +- Purple nodes are model or canonical data contracts. + +## 1. Current WinForms Architecture and MVC Pressure + +The current stack mixes model access, controller behavior, view creation, refresh policy, and native rendering inside the same path. This is why it is hard to test in isolation and why wrapping it in Avalonia would preserve the wrong boundary. + +```mermaid +flowchart TB + User["User input
keyboard, mouse, menus"]:::actor + Mediator["xCore Mediator
PropertyTable
command routing"]:::controller + RecordEdit["RecordEditView
screen host"]:::mixed + DataTree["DataTree
refresh, focus, layout,
slice ownership"]:::mixed + SliceFactory["SliceFactory
XML interpretation
editor selection"]:::mixed + Slices["Slices and launchers
WinForms controls
business decisions"]:::mixed + XMLViews["XMLViews browse/table views
view definitions plus rendering"]:::mixed + RootSite["RootSite / SimpleRootSite
managed/native bridge"]:::decom + NativeViews["Native Views C++
layout, measurement,
selection, hit testing,
editing"]:::decom + Graphite["Graphite engine
Graphite feature settings"]:::decom + Gecko["Gecko/XWebBrowser/PDF
Graphite-enabled preview/export"]:::decom + XMLParts["XML Parts/Layout
customer overrides
ghosts, choosers, visibility"]:::model + LCModel["LCModel
lexicon data
transactions"]:::model + WS["Writing systems
fonts, script metadata,
legacy Graphite flags"]:::model + Tests["Hard-to-isolate tests
UIA can drive shell;
owner-drawn content is opaque"]:::test + + User --> Mediator --> RecordEdit --> DataTree + DataTree --> SliceFactory --> Slices + SliceFactory --> XMLParts + Slices --> LCModel + Slices --> RootSite --> NativeViews + XMLViews --> RootSite + NativeViews --> Graphite + DataTree --> WS + NativeViews --> WS + Gecko --> Graphite + Gecko --> WS + DataTree -. MVC violation: view owns refresh and control policy .-> LCModel + SliceFactory -. MVC violation: view factory owns editor decisions .-> LCModel + Slices -. MVC violation: launchers mix UI and business rules .-> LCModel + Tests -. brittle / broad .-> DataTree + + classDef actor fill:#f8fafc,stroke:#64748b,color:#0f172a; + classDef controller fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef mixed fill:#fff7ed,stroke:#f97316,color:#431407; + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; +``` + +## 2. Dependency Inversion Path and Better MVC + +The first architectural move is not Avalonia. It is extracting narrow ports around refresh, view definitions, editor selection, edit transactions, command/focus routing, UI dispatch, lifetime, writing-system text, diagnostics, and retained linguistics services. Legacy WinForms becomes one adapter. Avalonia becomes another adapter later. + +```mermaid +flowchart LR + subgraph Model["Model and canonical contracts"] + LCModel["LCModel
data and transactions"]:::model + Canonical["Canonical view definition
layout semantics, editor descriptors"]:::model + Presentation["Instance presentation model
stable node identity,
binding, validation, focus metadata"]:::model + XMLImport["XML import adapter
transitional compatibility"]:::adapter + end + + subgraph Ports["Dependency-inverted ports"] + Refresh["ILexicalRefreshCoordinator"]:::port + ViewDefs["IViewDefinitionSource / Importer / Compiler / Cache"]:::port + Editors["ILexicalEditorRegistry"]:::port + EditSession["IEditSession
transactions, validation,
undo/redo"]:::port + Choosers["IChooserService"]:::port + Text["IWritingSystemTextService
font and shaping capabilities"]:::port + Command["IXCoreCommandBridge"]:::port + PropertyState["IPropertyStateStore"]:::port + Navigation["IRecordNavigationContext"]:::port + Scheduler["IUiScheduler"]:::port + Lifetime["IRegionLifetime"]:::port + Linguistics["Feature-specific linguistics services
spelling, XAmple, parsers"]:::port + Capture["IViewParitySnapshotService"]:::port + end + + subgraph LegacyAdapters["Legacy adapters during migration"] + LegacyHost["RecordEditView/DataTree adapter"]:::legacy + LegacySlices["Legacy slice adapter"]:::legacy + LegacyViews["Native Views baseline adapter"]:::decom + end + + subgraph FutureAdapters["Future adapters"] + AvaloniaHost["Avalonia screen host"]:::future + AvaloniaEditors["FieldWorks-owned Avalonia editors"]:::future + TableTree["Avalonia table/tree renderer"]:::future + end + + XMLParts["XML Parts/Layout"]:::legacy --> XMLImport --> Canonical + ViewDefs --> Canonical --> Presentation + LCModel --> Presentation + Presentation --> Editors + Presentation --> Capture + Refresh --> LegacyHost + Editors --> LegacySlices + Capture --> LegacyViews + Editors --> AvaloniaEditors + EditSession --> AvaloniaEditors + Refresh --> AvaloniaHost + Text --> AvaloniaEditors + Choosers --> AvaloniaEditors + Command --> AvaloniaHost + PropertyState --> AvaloniaHost + Navigation --> AvaloniaHost + Scheduler --> AvaloniaHost + Lifetime --> AvaloniaHost + Linguistics --> AvaloniaEditors + AvaloniaHost --> TableTree + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef adapter fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; +``` + +## 3. Testing and Validation Map + +Tests are layered around the seam being proven. Deep behavior moves to unit and integration tests. UI automation stays narrow. Render verification captures both semantic and visual evidence. Avalonia.Headless covers new controls without booting the full application. + +```mermaid +flowchart TB + Requirements["Migration requirements
density, interaction, fonts,
audited default path, no native viewing"]:::model + + Unit["Unit tests
refresh state
launcher logic
editor registry"]:::test + Integration["Integration tests
XML import to typed IR
LCModel transactions
cache invalidation"]:::test + Semantic["Semantic parity snapshots
fields, labels, bindings,
ghosts, focus, accessibility"]:::test + LegacyUIA["UIA2 legacy smoke
menus, dialogs, chooser launch,
table header reachability"]:::test + Render["Render comparison
near-pixel evidence
timing buckets
failure bundles"]:::test + Headless["Avalonia.Headless
input, focus, popups,
control behavior"]:::test + NativeAudit["Native viewing seam audit
no RootSite / IVwEnv / Views
inside completed region"]:::test + GraphiteAudit["Graphite/native rendering audit
no unapproved default-path
Graphite dependency"]:::test + UndoGate["Undo/redo and transaction matrix"]:::test + A11yGate["Accessibility, keyboard/IME,
localization gates"]:::test + OverrideGate["Customer override and
dynamic editor fixtures"]:::test + PerfGate["Performance budgets
open, scroll, type, memory"]:::test + RegionManifest["Migrated-region manifest
entry points, forbidden calls,
fixtures, rollback"]:::port + + Seam1["Refactor seams"]:::port + Seam2["Typed IR and XML import"]:::port + Slice1["First optional Avalonia slices"]:::future + Slice2["Tables and Lexical Edit regions"]:::future + Default["Default Avalonia readiness"]:::future + + Requirements --> Unit --> Seam1 + Requirements --> Integration --> Seam2 + Requirements --> Semantic --> Seam2 + Requirements --> LegacyUIA --> Seam1 + Requirements --> Render --> Slice2 + Requirements --> Headless --> Slice1 + Requirements --> NativeAudit --> Default + Requirements --> GraphiteAudit --> Default + Requirements --> UndoGate --> Slice1 + Requirements --> A11yGate --> Slice1 + Requirements --> OverrideGate --> Slice2 + Requirements --> PerfGate --> Slice2 + RegionManifest --> Slice1 + RegionManifest --> Slice2 + Seam1 --> Slice1 --> Slice2 --> Default + Seam2 --> Slice1 + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; +``` + +## 4. First Optional Avalonia Slices: Hover, Popup, Simple Editors + +The first slices should be optional and low-blast-radius. They use the same ports that legacy code uses, but the rendered surface is Avalonia-owned and can run in the Preview Host or headless tests. + +```mermaid +flowchart LR + subgraph LegacyShell["Existing WinForms shell remains default"] + RecordEdit["RecordEditView/DataTree"]:::legacy + EditorRegistry["Editor registry seam"]:::port + LegacySlice["Legacy slice fallback"]:::legacy + end + + subgraph OptionalAvalonia["Optional first Avalonia slice"] + PreviewHost["Preview Host or feature flag"]:::future + SimpleEditor["Simple text/scalar editor"]:::future + Hover["Hover card / popup chooser"]:::future + Headless["Avalonia.Headless tests"]:::test + end + + subgraph Contracts["Shared contracts"] + IR["Typed IR node"]:::model + Text["Writing-system text service
proven font/shaping paths"]:::port + EditSession["Edit session
commit/cancel, validation,
undo/redo"]:::port + CommandFocus["Command, focus,
keyboard/IME routing"]:::port + SchedulerLifetime["UI scheduler and
region lifetime"]:::port + Chooser["Chooser service"]:::port + Linguistics["Linguistics service gateway
spelling/XAmple allowed"]:::port + end + + Decom["Not allowed in this slice
RootSite, IVwEnv, native Views,
Graphite"]:::decom + + RecordEdit --> EditorRegistry + EditorRegistry --> LegacySlice + EditorRegistry --> PreviewHost + PreviewHost --> SimpleEditor + PreviewHost --> Hover + IR --> SimpleEditor + Text --> SimpleEditor + EditSession --> SimpleEditor + CommandFocus --> SimpleEditor + SchedulerLifetime --> SimpleEditor + CommandFocus --> Hover + Chooser --> Hover + Linguistics --> SimpleEditor + Headless --> SimpleEditor + Headless --> Hover + SimpleEditor -. must not call .-> Decom + Hover -. must not call .-> Decom + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; +``` + +## 5. Lexical Edit and Table Views Slice + +Table views and full Lexical Edit regions are meaningfully different from the first hover/simple-editor slices. They need virtualization, stable row/node identity, selection and scrolling services, table/tree templates, and stronger parity gates. + +```mermaid +flowchart TB + subgraph Inputs["Canonical inputs"] + Manifest["Migrated-region manifest
entry points, gates,
forbidden calls"]:::port + LCModel["LCModel data"]:::model + XMLImport["XML import
transition only"]:::adapter + IR["Typed view definition / IR
sections, fields, tables,
tree nodes, editor descriptors"]:::model + end + + subgraph AvaloniaRegion["Migrated table or Lexical Edit region"] + Host["Avalonia region host"]:::future + Virtualizer["Virtualized table/tree coordinator"]:::future + ControlChoice["Control choice adapter
TreeView, TreeDataGrid,
ItemsRepeater, owned controls"]:::future + Rows["Dense row/node templates
multiple writing-system alternatives"]:::future + Editors["Editor registry
cell and field editors"]:::future + Selection["Managed selection, focus,
scroll, hit-test metadata"]:::future + end + + subgraph Services["Ports and services"] + Refresh["Refresh coordinator"]:::port + Text["Writing-system text service
proven font/shaping paths"]:::port + Chooser["Chooser and popup services"]:::port + Linguistics["Custom linguistics services
XAmple/spelling/parsers"]:::port + Diagnostics["Diagnostics and parity capture"]:::port + end + + subgraph Gates["Completion gates"] + Semantic["Semantic parity"]:::test + Render["Render/timing evidence"]:::test + NativeAudit["No native viewing/rendering/editor path"]:::test + GraphiteAudit["No unapproved Graphite/native
default-path dependency"]:::test + Perf["Performance budget"]:::test + BrowserPdf["Browser/PDF decision gate"]:::test + end + + Decommissioned["Decommission for this region
DataTree slices, XMLViews runtime,
RootSite/IVwEnv/Native Views,
Graphite render engine"]:::decom + + LCModel --> IR + XMLImport --> IR + Manifest --> Host + IR --> Host --> Virtualizer --> ControlChoice --> Rows + Virtualizer --> Selection + Rows --> Editors + Refresh --> Host + Text --> Rows + Chooser --> Editors + Linguistics --> Editors + Diagnostics --> Semantic + Host --> Semantic + Host --> Render + Host --> NativeAudit + Host --> GraphiteAudit + Host --> Perf + Host --> BrowserPdf + Host -. must not call .-> Decommissioned + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef adapter fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; +``` + +## 6. Final Default Architecture After Avalonia and Graphite Decommissioning + +In the final Lexical Edit default path, MVC/MVVM boundaries are explicit: LCModel and canonical view definitions are model/data contracts; presenters and edit sessions coordinate commands, refresh, transactions, validation, and diagnostics; Avalonia controls own display and input. Graphite and native viewing/rendering are outside the default path. Retained native linguistics engines are service dependencies, not UI dependencies. Full application shell/window replacement is handled by the phase-two `fieldworks-avalonia-shell-migration` change. + +```mermaid +flowchart TB + subgraph ModelLayer["Model and canonical definitions"] + LCModel["LCModel
lexicon data and transactions"]:::model + Canonical["Canonical typed view definitions
post-XML runtime contract"]:::model + ProjectSettings["Project settings
writing systems, fonts,
font feature metadata"]:::model + end + + subgraph ControllerLayer["Controller / presenter layer"] + Presenter["Lexical Edit presenters
commands, refresh, selection policy"]:::controller + EditorRegistry["Editor registry"]:::port + Transaction["Edit session, transaction,
validation, undo/redo"]:::port + Diagnostics["Diagnostics and parity hooks"]:::port + end + + subgraph ViewLayer["Avalonia view layer"] + Shell["Avalonia Lexical Edit shell"]:::future + Detail["Dense detail editors"]:::future + Tables["Virtualized table/tree views"]:::future + Popups["Choosers, flyouts, hover cards"]:::future + end + + subgraph ServiceLayer["FieldWorks services"] + Text["Writing-system text service
proven font/shaping paths"]:::port + Linguistics["Custom linguistics gateway
XAmple, spelling, parsers,
Encoding Converters, ICU"]:::port + BrowserPdf["Non-Graphite browser/PDF strategy"]:::port + ShellPhase["Phase-two Avalonia app shell
fieldworks-avalonia-shell-migration"]:::port + end + + subgraph Decommissioned["Removed from default Avalonia path"] + DataTree["DataTree/Slice runtime"]:::decom + XMLRuntime["Runtime XML Parts/Layout"]:::decom + NativeViews["RootSite, IVwEnv,
ManagedVwWindow, Native Views"]:::decom + Graphite["Native Graphite render engines,
unapproved Graphite runtime"]:::decom + Gecko["Gecko Graphite rendering
GeckofxHtmlToPdf assumptions"]:::decom + end + + LCModel --> Presenter + Canonical --> Presenter + ProjectSettings --> Text + Presenter --> Shell + Presenter --> EditorRegistry + Presenter --> Transaction + Shell --> Detail + Shell --> Tables + Shell --> Popups + EditorRegistry --> Detail + EditorRegistry --> Tables + Text --> Detail + Text --> Tables + Linguistics --> Presenter + BrowserPdf --> Presenter + ShellPhase --> Shell + Diagnostics --> Presenter + Shell -. default path excludes .-> Decommissioned + + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef controller fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; +``` \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-command-focus.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-command-focus.md new file mode 100644 index 0000000000..446c08b6b0 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-command-focus.md @@ -0,0 +1,57 @@ +# Avalonia Command and Focus Plan + +Command and focus behavior must be built in two layers: local first-slice behavior that can run in the preview host, then FieldWorks shell/XCore routing for production integration. + +## Current State + +| Item | Source | Current Behavior | +|---|---|---| +| Local key bindings | `010-advanced-entry-preview-prototype` | Prototype has local `Ctrl+S` save and `Escape` cancel bindings. | +| View-model commands | `010-advanced-entry-preview-prototype` | Prototype has save/cancel commands and close request callback. | +| Shell bridge | Not implemented | No current `IXCoreCommandBridge` or production mediator adapter. | + +## First-Slice Local Contract + +The preview-host and first editable slice must support: + +- Keyboard save/cancel commands. +- Tab/Shift+Tab navigation in layout order. +- Focus restoration after validation failure, refresh, save failure, and popup close. +- Stable automation names/IDs for controls where Avalonia exposes them. +- No dependency on XCore mediator or WinForms message routing. + +## Shell-Phase Contract + +When integrated into FieldWorks, the migrated region must additionally: + +- Route menu, toolbar, and accelerator commands through XCore without duplicating command state. +- Keep global undo/redo, save, cancel, refresh, and navigation enablement in sync with active edit-session state. +- Avoid stealing shortcuts from focused text controls when local text editing should handle them. +- Return focus to the shell/record list predictably when the migrated region closes or rolls back. + +## Required Tests + +| Test Area | Cases | +|---|---| +| Local shortcuts | `Ctrl+S` invokes save once; `Escape` invokes cancel once; disabled commands do not execute. | +| Focus order | Tab order matches Presentation IR order and legacy baseline for selected fixture. | +| Validation focus | Save with blocking error focuses first invalid materialized node and exposes error metadata. | +| Refresh focus | Refresh/rebuild keeps focus on equivalent node when possible; otherwise chooses documented fallback. | +| Popup/chooser focus | Opening and closing chooser/popup restores focus and selection/caret. | +| Text control ownership | Text-edit shortcuts remain local until command bridge explicitly routes them. | +| Shell bridge | XCore menu/toolbar/keyboard command state matches view-model/session state. | + +## Architecture Notes + +- Keep first-slice commands as view-model commands so headless tests can exercise them. +- Introduce a shell command bridge only in the integration phase, behind an interface owned by the host composition layer. +- Do not route commands by directly referencing WinForms controls from Avalonia production code. +- Focus keys should be stable Presentation IR node IDs, not visual tree indexes. + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 5 | Local command/focus tests pass for first editable slice. | +| Phase 6 | Accessibility and keyboard traversal evidence exists for selected fixture. | +| Phase 8 | Shell bridge tests prove XCore command routing without breaking preview-host isolation. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-edit-sessions.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-edit-sessions.md new file mode 100644 index 0000000000..47bf080e65 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-edit-sessions.md @@ -0,0 +1,55 @@ +# Avalonia Edit Sessions + +This plan separates the current AdvancedEntry implementation from the proposed edit-session seam needed for production Lexical Edit migration. + +## Current State + +| Item | Source | Current Behavior | +|---|---|---| +| Concrete session | `010-advanced-entry-preview-prototype` | Prototype starts a fenced LCModel undo task for a selected entry, then exposes `Save()` and `Cancel()`. | +| View-model ownership | `010-advanced-entry-preview-prototype` | Prototype loads one entry lifetime, disposes it on save/cancel, and requests window close through a callback. | +| Existing coverage | `AdvancedEntryEditSessionTests`, `MainWindowViewModelLifetimeTests` | Save/cancel behavior, nested-session rejection, and basic lifetime disposal are characterized. | + +There is no implemented `ILexicalEditSession` with `GetValue`, `SetValue`, or `Commit` semantics. Any such API is a proposed Phase 3 seam. + +## Architectural Decision Needed + +Before first editable slice work expands, choose one model and encode it in tests: + +| Option | Pros | Risks | Required Tests | +|---|---|---|---| +| Direct LCModel fenced undo task | Matches current spike and existing LCModel action-handler behavior. | UI edits affect model before save if not staged carefully; cancel must reliably roll back all touched data. | Multi-field cancel rollback, save creates one undoable action, global undo after save, nested sessions rejected before mutation. | +| Staged draft model | Cleaner validation and cancel semantics before commit. | More code; must map drafts to LCModel objects and handle stale model state. | Draft isolation, conflict/stale object detection, commit transaction, rollback on partial failure. | + +Default recommendation for Phase 3: keep the current direct fenced undo-task model for the first slice, but add tests that prove cancel/save/global undo semantics before broadening editable fields. + +## Proposed Seam Contract + +The extracted seam should be introduced only with tests. It should provide: + +- One active session per editable root object unless nested sessions are explicitly designed. +- Explicit lifecycle states: `Active`, `Saved`, `Canceled`, `Disposed`, `Faulted`. +- Main-thread LCModel write enforcement. +- Deterministic cancellation/rollback even after validation errors. +- Save result that reports changed objects/flids for refresh coordination. +- No direct UI dependency, dialog dependency, or WinForms dependency. + +## Required Tests + +| Test Area | Cases | +|---|---| +| Lifecycle | Save once, cancel once, dispose without save, double save/cancel no-op or exception by contract, nested session rejected. | +| Rollback | Single-field rollback, multi-field rollback, sequence add/remove rollback, object creation rollback, stale reference rollback. | +| Commit | Save creates one undoable action; global undo/redo restores values; failure during commit does not leave partial state. | +| Refresh | Save reports changed objects/flids; cancel reports no committed changes; disposed session does not emit late refresh. | +| Threading | LCModel writes happen on approved thread; background validation/layout cannot mutate cache. | +| Localization/diagnostics | User-facing save/cancel errors use resources; diagnostics include entry HVO/class/flid without leaking unsafe input across native boundaries. | + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 2 | Current concrete session tests pass and gaps are recorded in the coverage report. | +| Phase 3 | Introduce seam with lifecycle/rollback contract tests before moving logic out of the view model. | +| Phase 5 | First editable slice proves save/cancel/global undo/redo against real LCModel fields. | +| Phase 8 | Shell integration proves XCore/global command routing uses the same session semantics. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-lifetime.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-lifetime.md new file mode 100644 index 0000000000..bd481682b7 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-lifetime.md @@ -0,0 +1,68 @@ +# Avalonia Lifetime and Disposal Plan + +The migrated Lexical Edit region must release sessions, loaders, subscriptions, and UI resources deterministically across save, cancel, close, navigation, and shell unload. + +## Current State + +| Item | Source | Current Behavior | +|---|---|---| +| View model lifetime | `010-advanced-entry-preview-prototype` | Prototype owns the currently loaded entry lifetime and disposes it on save/cancel. | +| Window close | `010-advanced-entry-preview-prototype` | Prototype wires close behavior for the preview-host window. | +| Existing coverage | `MainWindowViewModelLifetimeTests` | Save/cancel disposal and close request basics. | +| Proposed seam | `ILexicalLifetimeManager` | Not implemented. | + +## Lifetime Contract + +Each migrated region must define owners for: + +- Active edit session. +- Loaded presentation snapshot and lazy sequence materializers. +- Validation subscriptions and async validation runs. +- UI scheduler callbacks. +- LCModel event subscriptions / `PropChanged` listeners. +- Shell command bridge registrations. +- Popup/chooser/dialog resources. + +Disposal must be idempotent, must detach event handlers, and must not allow late callbacks to mutate a closed region. + +## Close and Navigation Semantics + +| Scenario | Required Behavior | +|---|---| +| Save then close | Save completes or reports failure; successful save disposes session and closes/returns to shell once. | +| Cancel then close | Cancel rolls back, disposes session, and closes/returns once. | +| Window close while dirty | Policy must be explicit: prompt, cancel, save, or block. First slice can choose a narrow policy but must test it. | +| Navigation to another entry | Old session is saved/canceled/disposed before new session becomes active. Late loader results from old entry are ignored/disposed. | +| External shell unload | Unregister commands/events and dispose region without depending on visual tree finalizers. | +| Fault during save/cancel | Region remains in documented state with rollback/diagnostic path; no double close. | + +## Required Tests + +| Test Area | Cases | +|---|---| +| Idempotent disposal | Dispose twice, save then dispose, cancel then dispose. | +| Late loader | Loader completes after cancel/navigation; result is disposed or ignored and does not overwrite current entry. | +| Event unsubscribe | LCModel/mediator/scheduler callbacks do not fire into disposed view model. | +| Close ordering | Close requested once after save/cancel; failure does not close unless policy says so. | +| Dirty close | Dirty close policy is tested, including prompt/dialog seam when introduced. | +| Popup/chooser | Open popup resources are closed/disposed on region unload. | +| Leak smoke | Weak-reference or subscription-count smoke test for common lifetime leaks when feasible. | + +## Proposed Lifetime Manager + +If the view-model lifetime logic grows beyond first slice, extract a seam with these responsibilities: + +- Track region lifecycle state. +- Own cancellation tokens for async work. +- Dispose old sessions before loading new roots. +- Coordinate close/navigation decisions with edit session, validation, scheduler, and shell bridge. +- Expose test hooks for active subscriptions/resources. + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 2 | Current save/cancel lifetime tests pass. | +| Phase 3 | Lifetime extraction starts only with late-loader and idempotent-disposal tests. | +| Phase 5 | First editable slice has dirty close/navigation behavior tested. | +| Phase 8 | Shell unload and command bridge registrations are disposed in integration tests. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-ui-scheduler.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-ui-scheduler.md new file mode 100644 index 0000000000..ef7600530b --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-ui-scheduler.md @@ -0,0 +1,63 @@ +# Avalonia UI Scheduler Plan + +Avalonia UI work must use the UI dispatcher deliberately, and LCModel work must not be pushed onto background threads unless it operates on immutable snapshots. This plan prevents the migration from hiding threading bugs behind `Task.Run`. + +## Current State + +| Item | Source | Current Behavior | +|---|---|---| +| Dispatcher use | `010-advanced-entry-preview-prototype` | Prototype uses Avalonia dispatcher calls directly for UI-bound operations. | +| Tests | Avalonia headless tests | Tests can flush dispatcher work, but there is no scheduler seam yet. | +| Proposed seam | `IUiScheduler` | Not implemented. | + +Avalonia documentation distinguishes fire-and-forget dispatcher posting from awaited invocation. Use the awaited path when tests and lifecycle code need completion, result, cancellation, or exception propagation. + +## Rules + +| Rule | Rationale | +|---|---| +| UI-bound mutations run on Avalonia UI thread. | Keeps visual tree/view-model notifications predictable. | +| LCModel writes run through approved edit-session/main-thread path. | LCModel and native boundaries are not safe targets for casual background work. | +| Background work uses immutable snapshots. | Layout compilation and validation can be parallelized only after data is copied into immutable inputs. | +| Await completion for lifecycle-critical work. | Save, cancel, close, validation, and loader disposal need exception/cancellation visibility. | +| Fire-and-forget must be rare and logged/owned. | `Post` can hide failure and create late callbacks after disposal. | + +## Proposed Scheduler Seam + +Introduce a thin seam only when tests need it: + +```csharp +public interface IUiScheduler +{ + bool CheckAccess(); + Task InvokeAsync(Func action, CancellationToken cancellationToken); + Task InvokeAsync(Func> action, CancellationToken cancellationToken); + void Post(Action action); +} +``` + +Contract details: + +- `InvokeAsync` propagates exceptions and observes cancellation before starting work when possible. +- `Post` is for non-critical notifications only; tests must not rely on it as completed work. +- The scheduler does not own LCModel threading policy; edit-session services do. + +## Required Tests + +| Test Area | Cases | +|---|---| +| Access | `CheckAccess` returns true on UI thread and false in fake/background contexts. | +| Exception | Exception thrown in invoked action reaches caller and does not leave session half-disposed. | +| Cancellation | Canceled token prevents queued lifecycle work or reports cancellation deterministically. | +| Disposal | Late scheduled loader result is disposed/ignored after cancel/close. | +| Ordering | Save/cancel/close ordering is deterministic under queued UI work. | +| Snapshot work | Background layout/validation tests prove inputs are immutable and no live `LcmCache` mutation occurs. | + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 3 | Add scheduler seam only with fake-scheduler tests that prove lifecycle behavior. | +| Phase 4 | Layout compiler background work consumes immutable snapshots. | +| Phase 5 | Save/cancel validation paths use awaited scheduling where completion matters. | +| Phase 8 | Shell integration has cancellation and disposal tests for region unload/navigation. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-undo-redo.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-undo-redo.md new file mode 100644 index 0000000000..91e79ea580 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-undo-redo.md @@ -0,0 +1,53 @@ +# Avalonia Undo/Redo Plan + +Undo/redo is a shell and LCModel contract, not a local Avalonia convenience. The migrated editor must integrate with the existing FieldWorks action-handler behavior before it can be enabled by default. + +## Current State + +| Area | Current Behavior | +|---|---| +| Legacy app | LCModel write operations are normally wrapped in undoable tasks and participate in the global FieldWorks undo/redo stack. | +| AdvancedEntry spike | Prototype on `010-advanced-entry-preview-prototype` uses a concrete save/cancel session. Local UI currently exposes save/cancel, not a full undo/redo coordinator. | +| Tests | Edit-session tests characterize save/cancel/nested-session behavior, but do not yet prove global undo/redo after save. | + +`IUndoRedoCoordinator` is a proposed Phase 3/8 seam. It is not current implementation. + +## Contract + +The migrated region MUST: + +1. Wrap committed model changes in the same undo/redo mechanism expected by LCModel and the FieldWorks shell. +2. Avoid creating a separate Avalonia-only undo history for committed LCModel state. +3. Keep transient text-edit undo local to the focused text control only until the edit commits. +4. Disable or route global undo/redo commands while a session is in a state where replay would corrupt the draft/LCModel boundary. +5. Refresh the migrated region after external undo/redo without losing focus when possible. + +## Routing Model + +| Command Source | First-Slice Behavior | Shell-Integrated Behavior | +|---|---|---| +| `Ctrl+Z` / `Ctrl+Y` inside text control | Let the control handle local text undo while focus remains in an uncommitted editor. | Same, unless shell command routing explicitly owns the shortcut. | +| Save command | Commits through edit session and creates one LCModel undoable action. | Also updates shell command state and dirty indicators. | +| Global Undo/Redo menu/toolbar | Out of scope for preview spike. | Routed through an `IUndoRedoCoordinator` that delegates to LCModel action handler and refreshes the region. | +| Cancel | Rolls back active session and must not create a committed undo action. | Same, with shell state notification. | + +## Required Tests + +| Test Area | Cases | +|---|---| +| Commit grouping | Multiple field edits saved together produce one undoable action. | +| Global undo | After save, global undo restores all changed LCModel values and refreshes the Avalonia view. | +| Global redo | Redo reapplies saved values and refreshes without duplicating sequence items. | +| Cancel | Cancel restores values and does not add an undoable action. | +| Focus | Undo/redo after save keeps or restores a sensible focus target; destroyed editors do not receive focus. | +| Dirty state | Command enablement reflects clean, dirty, saving, canceled, and faulted states. | +| External mutation | Legacy DataTree or parser mutation during/after session is detected and refreshed or rejected safely. | + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 3 | Extract coordinator only after edit-session lifecycle tests exist. | +| Phase 5 | First editable slice has save/cancel/global undo/global redo tests on real fields. | +| Phase 8 | Shell command bridge proves XCore menu/toolbar/keyboard routing against the same coordinator. | +| Phase 9 | Default-enabled region passes external undo/redo refresh tests and retains rollback. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/avalonia-validation.md b/openspec/changes/lexical-edit-avalonia-migration/avalonia-validation.md new file mode 100644 index 0000000000..1232c347c5 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/avalonia-validation.md @@ -0,0 +1,63 @@ +# Avalonia Validation Plan + +Validation must protect LCModel data, expose useful user feedback, and remain testable without assuming UI behavior that is not yet implemented. + +## Current State + +| Item | Source | Current Behavior | +|---|---|---| +| Validation service | `010-advanced-entry-preview-prototype` | Prototype evaluates required Presentation IR nodes, skips unmaterialized lazy sequence items, returns deterministic errors in layout order. | +| Coverage | `010-advanced-entry-preview-prototype` | Prototype covers required-field ordering and lazy skip behavior. | +| UI binding | Current property-grid prototype | No complete production binding adapter yet. | + +Avalonia supports validation through binding mechanisms such as `INotifyDataErrorInfo` and `DataValidationErrors`, but the current spike has not wired a full production validation presentation layer. + +## Required Validation Model + +| Field | Requirement | +|---|---| +| `NodeId` | Stable presentation node ID for focus and error placement. | +| `ObjectId` / class / flid | Enough LCModel context for diagnostics and refresh. | +| `Severity` | Error, warning, info. Save-blocking behavior depends on severity. | +| `ResourceKey` | Localizable message key, with formatted parameters separated from text. | +| `Message` | Localized display text at presentation time. | +| `AccessibilityText` | Screen-reader-friendly error summary where Avalonia supports exposing it. | +| `Version` | Validation run/version used to ignore stale async results. | + +## Architecture + +1. Validation rules operate on immutable presentation/edit-session snapshots where possible. +2. LCModel-dependent rules run on the approved thread or use immutable metadata/value snapshots. +3. The view model exposes validation through an Avalonia-friendly adapter, preferably `INotifyDataErrorInfo` when it maps cleanly to the control surface. +4. UI controls display validation state without hardcoding production strings in XAML or code. +5. Save command enablement depends on save-blocking validation errors, not on visual state alone. + +## Required Tests + +| Test Area | Cases | +|---|---| +| Determinism | Errors ordered by layout/focus order, independent of dictionary enumeration. | +| Lazy data | Unmaterialized sequences skipped; materialized invalid child reports correct node. | +| Localization | Error carries resource key and arguments; localized message resolves in presentation layer. | +| Severity | Warnings do not block save; errors block save; policy is explicit. | +| Async/stale results | Slow validation result from older snapshot is ignored after newer edit. | +| Accessibility | Error summary/automation metadata is exposed for focused invalid controls where supported. | +| Save interaction | Save refuses blocking errors and does not partially commit; cancel remains available. | +| External mutation | Deleted or replaced LCModel objects produce deterministic stale-object diagnostics. | + +## Open Decisions + +| Decision | Notes | +|---|---| +| `INotifyDataErrorInfo` vs direct `DataValidationErrors` | Prefer the smallest adapter that lets controls and tests observe the same errors. | +| Sync vs async rules | First slice can stay synchronous for required-field rules; async rules need cancellation/versioning first. | +| Error placement for virtualized nodes | Non-materialized invalid data needs region-level summary and a way to materialize/focus the node. | + +## Phase Gates + +| Phase | Gate | +|---|---| +| Phase 2 | Core service tests pass and known UI-binding gaps are recorded. | +| Phase 5 | First editable slice exposes validation in the view model and blocks save only by explicit severity policy. | +| Phase 6 | Accessibility and keyboard navigation can reach validation feedback. | +| Phase 8 | Shell dirty/save state reflects validation state. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md b/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md new file mode 100644 index 0000000000..f12aee3fc4 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md @@ -0,0 +1,117 @@ +# Coverage Map: Lexical Edit Avalonia Migration + +This map records the characterization coverage needed before refactoring the standard Lexical Edit path toward Avalonia. It separates current repo behavior from proposed seams so Phase 3 does not proceed on invented interfaces. + +This Phase 1/2 foundation branch keeps legacy WinForms/DataTree/XMLViews characterization and planning. The Avalonia Preview Host and `AdvancedEntry.Avalonia` prototype coverage referenced below lives on `010-advanced-entry-preview-prototype`; product launcher wiring lives on `010-advanced-entry-product-launcher-spike`. + +## Coverage Status Legend + +| Status | Meaning | +|---|---| +| Covered | Executable characterization exists for the current boundary. | +| Partial | Some executable coverage exists, but named edge cases remain open. | +| Planned seam | No production seam exists yet; tests must be added during or before extraction. | +| Blocked | Clean tests require a new seam, harness, or approved dependency. | + +## 1. DataTree Refresh and Slice Output + +| Surface | Current Source | Current Coverage | Missing Before Risky Refactor | +|---|---|---|---| +| `PropChanged` handling | [Src/Common/Controls/DetailControls/DataTree.cs](Src/Common/Controls/DetailControls/DataTree.cs#L639) | LT-22414 refresh tests in [MorphTypeAtomicLauncherTests.cs](Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs) | Nested/re-entrant `DoNotRefresh`, rapid deferred notifications, focus restoration after refresh. | +| Full refresh/rebuild | [Src/Common/Controls/DetailControls/DataTree.cs](Src/Common/Controls/DetailControls/DataTree.cs#L1967) | `DoNotRefresh_*` regression coverage | Explicit refresh coordinator tests once `ILexicalRefreshCoordinator` exists. | +| Slice semantic output | [Src/Common/Controls/DetailControls/DataTree.cs](Src/Common/Controls/DetailControls/DataTree.cs#L1879) and [Src/Common/Controls/DetailControls/Slice.cs](Src/Common/Controls/DetailControls/Slice.cs) | `CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` | Broader fixture set covering ghost slices, nested sequences, custom fields, and accessibility identity. | + +Phase 3 must keep the legacy tests green while extracting refresh coordination. The planned `ILexicalRefreshCoordinator` is not a current repo type. + +## 2. SliceFactory and Editor Resolution + +| Surface | Current Source | Current Coverage | Missing Before Registry Extraction | +|---|---|---|---| +| Legacy editor factory | [Src/Common/Controls/DetailControls/SliceFactory.cs](Src/Common/Controls/DetailControls/SliceFactory.cs) | `SliceFactoryTests.SetConfigurationDisplayPropertyIfNeeded_Works`; `Create_UnknownEditor_ReturnsMessageSliceWithEditorAccessibleName` | Matrix for common editor keys (`string`, `multistring`, `jtview`, `possatomic`, `custom`, `customwithparams`, `autocustom`) and reuse-map compatibility. | +| Launcher dispatch | [Src/Common/Controls/DetailControls/Slice.cs](Src/Common/Controls/DetailControls/Slice.cs#L2772) plus launcher subclasses | Morph-type launcher click smoke reaches the chooser decision path without opening modal UI | Extracted chooser-result model and chooser adapter tests for full OK/Cancel semantics. | +| Proposed registry | Planned `ILexicalEditorRegistry` boundary | None yet | Contract tests proving unknown editors emit diagnostics, known editors resolve deterministically, and legacy fallback remains available. | + +Do not describe `ILexicalEditorRegistry` or a `GetEditorType` implementation as current code. They are Phase 3 extraction targets. + +## 3. Launchers, Choosers, and Morph Type Swap + +| Surface | Current Source | Current Coverage | Gap / Blocker | +|---|---|---|---| +| Morph classification | [Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs](Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs) | Full `IsStemType` GUID matrix | Covered for current extraction target. | +| Data-loss decisions | [Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs](Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs#L226) and [Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs](Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs#L345) | Empty no-prompt tests plus pure classifiers for stem-name, inflection-class, infix-location, grammatical-info, and rule loss | The final yes/no prompt response still uses `MessageBox.Show`; Phase 3 should put that behind a dialog-service seam. | +| Chooser OK/Cancel | `MorphTypeChooser`, `ReallySimpleListChooser`, `ChooserCommand` | Launcher click path reaches chooser decision code for a valid object | Full OK/Cancel selection semantics remain blocked until modal UI and selection decisions are behind a humble-object or dialog-service seam. | +| Refresh side effects | `MorphTypeAtomicLauncher.SwapValues` | LT-22414 refresh tests | Focus restoration and obsolete-slice disposal need focused tests during Phase 3. | + +The proposed `MorphTypeSwapController` does not exist. Phase 3 should extract from `MorphTypeAtomicLauncher`, starting with classification, data-loss issue detection, chooser result interpretation, and refresh/focus side-effect orchestration. + +## 4. XMLViews Browse, Tables, and Choosers + +| Surface | Current Source | Current Coverage | Missing Tests | +|---|---|---|---| +| Browse host | [Src/xWorks/RecordBrowseView.cs](Src/xWorks/RecordBrowseView.cs) | Existing xWorks tests plus `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` for header order and filter/chooser reachability | Sort-state and keyboard-navigation baselines remain for table migration work. | +| XML table renderer | [Src/Common/Controls/XMLViews/XmlView.cs](Src/Common/Controls/XMLViews/XmlView.cs) | Existing XMLViews reset/refresh tests | UIA2 or equivalent smoke harness before claiming parity. | +| Chooser forms | [Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs](Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs) and [Src/Common/Controls/XMLViews/ChooserCommandBase.cs](Src/Common/Controls/XMLViews/ChooserCommandBase.cs) | Existing isolated chooser tests, not enough for migration parity | Keyboard search, expand/collapse, double-click commit, cancel, invalid target, and transaction rollback. | + +A net48 `System.Windows.Automation` harness now exists for `FwAvaloniaPreviewHost` (`FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs`). Legacy WinForms launcher/chooser/XMLViews parity still uses in-process smoke substitutes on this branch; a full WinForms UIA2/FlaUI parity harness remains a later infrastructure decision. + +## 5. Layout Overrides and Dictionary Configuration + +| Surface | Current Source | Existing Evidence | Required Baselines | +|---|---|---|---| +| Part/layout inventory | `DistFiles/Language Explorer/Configuration/Parts` | Prototype loader/snapshot coverage split to `010-advanced-entry-preview-prototype` | Shipped `LexEntry`, `LexSense`, `Morphology`, `CmPossibility`, and custom-field placeholder fixtures still need foundation-level parity evidence before XML retirement. | +| Runtime layout cache | `LayoutCache` / `Inventory` usage in xWorks and XMLViews | Existing xWorks migrator tests | Prototype default/override fixture coverage lives on `010-advanced-entry-preview-prototype`; selected production override fixtures still need acceptance evidence. | +| Dictionary/reversal configs | [Src/xWorks/DictionaryConfigurationMigrator.cs](Src/xWorks/DictionaryConfigurationMigrator.cs) and migrator tests | Broad migration tests under `Src/xWorks/xWorksTests/DictionaryConfigurationMigrators` | Selected customer-style config fixtures with expected typed IR and failure artifacts. | +| CSS/browser styling | [Src/xWorks/CssGenerator.cs](Src/xWorks/CssGenerator.cs) and XHTML/preview paths | Existing export tests | Explicit decision: outside migrated default path, converted to Avalonia resources, or preserved for legacy preview/export only. | + +Override handling must be evidence-first: every selected fixture needs input XML/CSS, expected typed definition or diagnostic output, and an artifact path on mismatch. + +## 6. AdvancedEntry Avalonia Seams + +The implementation and net8 test evidence for these seams has been split to `010-advanced-entry-preview-prototype`. This foundation branch keeps the seam map so Phase 3 work does not treat prototype coverage as production parity. + +| Seam | Current Source | Current Coverage | Required Before First Editable Slice | +|---|---|---|---| +| Edit session | Prototype branch | Save/cancel/nested-session tests on prototype branch | Decide direct LCModel fenced undo-task vs staged draft semantics; add global undo/redo-after-save tests before product editing. | +| Validation | Prototype branch | Required-field, deterministic order, lazy skip tests on prototype branch | `INotifyDataErrorInfo` or `DataValidationErrors` adapter, localization/resource key, severity, async stale-result suppression. | +| Command/focus | Prototype branch | Local shortcut and view-model command tests on prototype branch | Text-editor focus/caret restore and popup focus return remain Phase 6 control work. XCore bridge remains shell-phase work. | +| UI scheduling | Prototype branch | Headless dispatcher tests on prototype branch | Thin scheduler fake with cancellation, exception propagation, and no false completion for `Post`. | +| Lifetime | Prototype branch | Save/cancel lifetime, late-loader disposal, close cancellation, and DataContext unsubscribe checks on prototype branch | Broader leak instrumentation remains for shell/global lifetime work. | + +## 7. Snapshot Normalization + +| Surface | Current Source | Current Coverage | Remaining Phase 4 Work | +|---|---|---|---| +| Presentation IR semantic snapshots | Prototype branch | Normalized LexEntry detail snapshot coverage moved to `010-advanced-entry-preview-prototype` | Replace placeholders with first-class class/flid/object/writing-system metadata once the typed definition model carries them; add foundation-level fixtures before claiming production parity. | + +## 8. Hard Gates Before Phase 3 Refactor + +Phase 3 seam extraction should not start for a surface until one of these is true: + +1. The current behavior has executable characterization tests listed in this map. +2. The gap is explicitly blocked by a planned seam and the Phase 3 task includes the first test to write. +3. The behavior is consciously deferred with owner/risk notes in the relevant plan doc. + +Additional global gates: + +- `git diff --check` must be clean. +- Relevant `./test.ps1 -TestProject ...` commands must pass for touched areas. +- `openspec validate lexical-edit-avalonia-migration --strict` must pass after task/doc changes. +- Any default-path migration claim must include a forbidden-symbol audit, Graphite/native viewing proof, accessibility metadata checks, localization/resource checks, and rollback/default-off evidence. + +## 9. Path 3 Bundle: Semantic + Visual + Accessibility/Workflow + +Path 3 is the migration-quality lane for judging visual fidelity: one scenario bundle combines semantic parity, visual/density parity, and accessibility/workflow parity so reviewers and AI can inspect the same evidence set. + +| Lane | Source of Truth | Current Status | Path 3 Blocking Gaps | +|---|---|---|---| +| Semantic parity | `DataTreeTests` semantic baseline + typed IR snapshots | Partial | Broader fixture set for ghost/custom-field/accessibility identity; selected override fixtures. | +| Visual parity | WinForms render baselines and Avalonia rendered frames/screenshots | Partial | Canonical scenario bundle format; live Avalonia screenshots once the host work lands; matched DPI/framing rules. | +| Workflow/accessibility parity | UIA2/FlaUI/Appium on live windows; in-repo smoke substitutes meanwhile | Partial | Task 2.4 true UIA2/FlaUI baselines; remaining 2.7 keyboard/IME/focus-restoration/localization work. | +| Failure evidence | `RenderFailureArtifactBundler`, semantic snapshots, trace/log output | Partial | Unified failure summary id shared across semantic, visual, and workflow lanes. | + +Path 3 blockers before a region can claim strong visual fidelity: + +1. A scenario bundle must exist with all three lanes or a documented reason one lane is still pending. +2. Avalonia.Headless evidence is sufficient only for control-level visual behavior; it does not replace desktop workflow/accessibility evidence. +3. Live WinForms/Avalonia screenshots must use matched framing, DPI, and state whenever density or wrapping is under review. +4. A failure report must classify the broken lane rather than emitting only a raw image diff. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/design.md b/openspec/changes/lexical-edit-avalonia-migration/design.md new file mode 100644 index 0000000000..8b43b5b527 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/design.md @@ -0,0 +1,221 @@ +## Context + +Lexical Edit currently depends on a WinForms/DataTree/DetailControls stack that interprets XML Parts/Layout into `Slice` controls, launchers, chooser dialogs, nested `ViewSlice` content, and Views-backed rendering. The Advanced Entry Speckit work under `specs/010-advanced-entry-view/` already proves several useful ideas: a net8 Avalonia module, Preview Host, Presentation IR, XML contract loading, caching, headless tests, and parity checklist. The new target is larger: migrate the real Lexical Edit surface while preserving user interaction, density, writing-system behavior, and customizability, then retire XML after the Avalonia switch is proven. + +This branch is the foundation slice: it documents the architecture, keeps legacy characterization tests that protect Phase 3 refactors, and now includes a net48 `FwAvaloniaPreviewHost` + preview-host UIA smoke tests for the current POC module. The older net8 Preview Host/AdvancedEntry prototype remains intentionally split to `010-advanced-entry-preview-prototype`, and product launcher wiring is intentionally split to `010-advanced-entry-product-launcher-spike`. + +Important current constraints: +- `DataTree`, `Slice`, `SliceFactory`, launchers, `RecordEditView`, XMLViews browse/table views, and xCore mediator behavior are tightly coupled. +- XML Parts/Layout carries real customer customizations and behavior such as custom fields, ghost items, visibility rules, and chooser hints. +- Render verification exists for WinForms/DataTree pixel and timing baselines, but it needs semantic snapshots to compare legacy, IR, and Avalonia outputs. +- Native Views/C++ viewing/rendering remains a real dependency for legacy regions. Avalonia migration is only complete for a region after that region no longer instantiates or calls native viewing/rendering/editor code for display, layout, measurement, hit testing, selection, scrolling, or editor realization. +- Graphite is present in the native Graphite/Views rendering path (`GraphiteEngine`, `GraphiteSegment`, render-engine selection), writing-system UI/storage (`IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite`), render tests, sample/dist assets, and build/package artifacts. +- Gecko/XULRunner is initialized during FieldWorks startup with `gfx.font_rendering.graphite.enabled = true`; `XWebBrowser` and `GeckofxHtmlToPdf` support XHTML preview, print, and PDF/export paths. +- Avalonia offers headless testing, `TextBox`, `TreeView`, `TreeDataGrid`, `ItemsRepeater`, `FlyoutBase`/context menus, styles, `FontFeatures`, and custom font hooks, but FieldWorks needs owned controls for dense, writing-system-aware editing. + +## Goals / Non-Goals + +**Goals:** +- Make Lexical Edit refactorable and testable before replacing major UI surfaces. +- Use XML Parts/Layout as an import/compatibility contract during transition, not as the final runtime abstraction. +- Introduce typed view-definition and Presentation IR interfaces suitable for dependency injection, semantic parity tests, and Avalonia rendering. +- Preserve interaction behavior, information density, writing-system fonts, OpenType/HarfBuzz shaping behavior, nested structures, popup choosers, table views, and TreeView-heavy views. +- Decommission native Graphite/rendering from the default Lexical Edit path: Graphite work starts when the migration starts, and Avalonia does not become the default screen until Graphite dependencies are classified and either replaced, retained behind legacy fallback/export boundaries, or blocked with explicit diagnostics and rollback. +- Decommission C++ viewing/rendering dependencies by migrated region so completed Avalonia regions do not use native Views, `RootSite`, `IVwEnv`, `ManagedVwWindow`, or equivalent C++ display/layout/editor infrastructure at runtime. Custom linguistics services may remain when they are exposed through explicit service seams and do not own Avalonia viewing or editing surfaces. +- Extend render verification to capture semantic output, not only pixels and timings. + +**Non-Goals:** +- No one-shot rewrite of DataTree, XMLViews, and Lexical Edit. +- No immediate XML deletion. XML retirement waits for migration tooling and parity gates. +- No global native Views deletion before all consumers are migrated or explicitly retained. During transition, native Views can remain for non-migrated regions and baseline comparison, but not inside a completed Avalonia region. +- No unproven Graphite compatibility claim in Avalonia. Graphite-only fonts and feature strings are migration inputs to audit, warn, convert where possible, replace, or explicitly block; they are not assumed safe runtime targets. +- No promise of exact pixel parity with WinForms/C++ Views. The target is near-pixel parity with stable interaction semantics and density. + +## Decisions + +### 1. Refactor first, then Avalonia + +**Decision:** Sequence the work as test coverage and seams, then simple controls/popups, then table views, then slices and full Lexical Edit views. + +**Rationale:** DataTree refresh, slice creation, launcher behavior, and XML resolution are high-risk hidden dependencies. Avalonia work that starts by wrapping `DataTree` would preserve the wrong boundary and make regressions harder to identify. + +**Alternatives considered:** +- Direct Avalonia rewrite: too risky because XML semantics, refresh behavior, and chooser logic would be reimplemented without baselines. +- Embed Avalonia inside existing slices: useful for isolated experiments, but not a migration architecture. + +### 2. Typed view definition is the long-term contract + +**Decision:** Introduce a managed typed view-definition model and Presentation IR. XML Parts/Layout is imported into this model during transition; Avalonia consumes the typed model, not XML or WinForms slices. + +**Rationale:** This keeps customer customizations alive while creating a clean boundary for DI, tests, and eventual XML retirement. It also lets the render framework compare legacy XML-derived output with future non-XML definitions. + +**Alternatives considered:** +- Keep XML as permanent contract: preserves compatibility but does not solve maintainability. +- Pure LCModel metadata-generated UI: attractive for 90% model-following, but insufficient for current grouping, ghost items, and chooser behavior without many overrides. + +### 3. Owned Avalonia controls over permanent generic PropertyGrid dependence + +**Decision:** Use the existing PropertyGrid path as a bootstrap, then move Lexical Edit to FieldWorks-owned dense controls over IR nodes. + +**Rationale:** Stock property grids are poor fits for nested senses/examples, multi-writing-system alternatives, custom chooser flyouts, dense table rows, TreeView nodes with multiple translations, and FieldWorks-specific text behavior. + +**Framework grounding:** Avalonia supports headless tests, `TextBox` input, `TreeView`/`TreeDataTemplate`, `TreeDataGrid` template columns, `ItemsRepeater`, flyouts/context menus, styles/classes, `FontFeatures`, and font-manager hooks. Those are enough for much of the UI, but the composition and editor registry should be FieldWorks-owned. + +### 4. UIA2 for legacy smoke, Avalonia.Headless for new UI + +**Decision:** Use UIA2/FlaUI-style automation to baseline WinForms workflow reachability and accessibility metadata. Use Avalonia.Headless for Avalonia control behavior, input, and selected screenshots. Put business logic in unit/integration tests. + +**Rationale:** WinForms owner-drawn and Views-backed content is not deeply inspectable via UIA. UIA2 can still drive focus, menus, chooser launch, dialogs, and table headers. Avalonia.Headless gives invisible input tests and optional frame capture for the new UI. + +### 5. Semantic parity is a first-class render artifact + +**Decision:** Extend render verification with semantic snapshots: visible fields, labels, object/flid binding, editor kind, ghost state, expansion state, focus order, accessibility identity, writing-system metadata, and timing buckets. + +**Rationale:** Pixel comparisons catch visual regressions but do not explain whether a missing field is an XML compiler issue, slice filtering issue, editor registry issue, or text rendering difference. + +### 6. C++ viewing/rendering decommissioning is a regional completion gate + +**Decision:** A migrated Avalonia region is not complete until it has no runtime dependency on native Views/C++ code that owns viewing, display, layout, measurement, hit testing, selection, scrolling, or editor realization. Legacy C++ viewing/rendering may remain temporarily for non-migrated regions and for baseline comparison, but completed Avalonia regions must render and edit through Avalonia-managed controls and text services. + +**Rationale:** This keeps the end state honest. If Avalonia renders a surface but still relies on `RootSite`, `IVwEnv`, `ManagedVwWindow`, or the native Views box/render pipeline for core display, the migration has only wrapped the old system rather than replaced it. + +**Feasibility:** This is feasible by region if we treat C++ viewing/rendering removal as a phased dependency audit rather than a single repo-wide deletion. With Graphite decommissioned instead of supported, the meaningful choices move to font replacement, OpenType feature storage, and shared native Views consumers. Custom linguistics engines such as XAmple, spelling, parser/conversion tools, ICU, or Encoding Converters can remain when wrapped as services outside the Avalonia render/editor path. Physical deletion of shared native Views code can happen only after every consumer outside the region is migrated or intentionally retained. + +**Alternatives considered:** +- Keep native Views under Avalonia for hard text/layout cases: faster short term, but violates the migration goal and keeps the C++ viewing/rendering dependency alive. +- Delete native Views globally first: not feasible because other FieldWorks regions still depend on it and would lose functionality before replacements exist. + +### 7. Graphite and native rendering are evidence-gated before Avalonia becomes default + +**Decision:** Graphite/native-rendering decommissioning begins at the start of the Lexical Edit Avalonia migration. The default Avalonia Lexical Edit path must not depend on native Graphite render engines, Gecko Graphite rendering, or unclassified Graphite-only feature settings. Legacy fallback/export consumers may remain only when explicitly classified outside the migrated default path. + +**Rationale:** Avalonia documents custom TrueType/OpenType fonts and OpenType `FontFeatures`, but that does not prove FieldWorks Graphite parity. HarfBuzz Graphite2 shaping requires HarfBuzz to be built with Graphite2 enabled, and HarfBuzz documentation says that support is not enabled by default. Graphite behavior therefore needs fixture evidence, not assumption. + +**Research map:** The decommissioning scope includes `Src/views/lib/GraphiteEngine.*`, `Src/views/lib/GraphiteSegment.*`, `RenderEngineFactory`, Graphite feature UI/storage, persisted writing-system flags/features such as `IsGraphiteEnabled` and `DefaultFontFeatures`, Graphite-specific tests/docs/sample fonts, build/package artifacts, Gecko startup preference `gfx.font_rendering.graphite.enabled`, `XWebBrowser` preview consumers, and `GeckofxHtmlToPdf`/`FieldWorksPdfMaker` print/PDF assumptions. + +**Feasibility:** Feasible by region, but intentionally disruptive for Graphite-only fonts. There is no automatic lossless Graphite-to-OpenType conversion. The migration must identify affected projects/fonts, provide replacement OpenType fonts or explicit user-facing compatibility warnings, and block default enablement for unsupported cases. + +**Alternatives considered:** +- Assume Avalonia/HarfBuzz covers Graphite: rejected because official docs make Graphite2 shaping build-dependent. +- Keep Gecko only for Graphite previews/PDFs in the default workflow: rejected for migrated default Lexical Edit, but possible as a classified legacy/export boundary. + +### 8. Region manifests and hard gates define completion + +**Decision:** Every migrated Avalonia region must have a region manifest before implementation: entry points, typed view-definition sources, allowed legacy adapters, forbidden native viewing/rendering and Graphite symbols, retained custom linguistics service dependencies, parity fixtures, customer override fixtures, accessibility IDs, performance budgets, default-switch gates, and rollback behavior. + +**Rationale:** “Migrated region” needs to be measurable. A manifest turns architecture intent into a testable contract and prevents accidental native Views, Graphite, WinForms, Gecko, or runtime XML dependencies from slipping through a narrow visible slice. + +### 9. Avalonia platform services are explicit ports + +**Decision:** Avalonia regions use explicit platform-facing ports for UI dispatch, region lifetime/disposal, focus navigation, command routing, edit sessions, validation, undo/redo grouping, design/preview data, styling resources, and accessibility metadata. These ports are introduced while WinForms remains the default so legacy behavior can be characterized first. + +**Rationale:** Avalonia has different threading, focus, command, validation, popup, and lifetime behavior than WinForms. If those seams remain implicit, the first “simple” editor will inherit DataTree and xCore assumptions through the side door. + +### 10. Full shell/window replacement is a separate phase-two change + +**Decision:** This change remains scoped to Lexical Edit regional migration. Replacement of FieldWorks startup, main windows, shell composition, menus, toolbars, navigation, dialogs, and all main screens is tracked separately by `fieldworks-avalonia-shell-migration`. + +**Rationale:** Lexical Edit proves the hardest regional rendering/editor path. The app shell touches different risks: application lifetime, multi-window behavior, xCore command routing, project startup/shutdown, dialog ownership, persisted layout, global services, installer/runtime packaging, and remaining main screens. Splitting keeps both plans reviewable and gives the shell phase concrete prerequisites. + +### 11. Seam recommendations are fixed in dedicated capability specs with explicit phase timing + +**Decision:** This change records seam recommendations in dedicated capability specs: `avalonia-edit-sessions`, `avalonia-undo-redo`, `avalonia-validation`, `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime`. Detailed comparison notes, alternatives considered, current/proposed status, and source references are tracked in `seam-recommendations.md`. + +**Rationale:** Edit sessions, undo/redo, validation, command/focus, scheduler, and lifetime are the places where a migration can quietly hard-code a wrong abstraction. Freezing the recommendation and the pivot options in dedicated specs makes those choices reviewable, testable, and reusable by the later shell change. + +**Phase map:** +- Up front and before non-view Avalonia code spreads: introduce `avalonia-ui-scheduler` and `avalonia-lifetime` seams only where tests need UI-thread marshalling, cancellation, disposal, or late-callback control. +- First editable slice: apply `avalonia-edit-sessions`, `avalonia-undo-redo`, `avalonia-validation`, and the screen-local phase of `avalonia-command-focus` before scaling to broader editable regions. +- Phase-two shell migration: invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` instead of redefining them there. +- Deferred or separate-track options: package-first edit sessions, package-first undo/redo, and heavy region-lifetime frameworks remain available only if the pivot triggers documented in `seam-recommendations.md` are met. + +## Native Dependency Classification + +The classification rule is based on the role of the native code, not the implementation language alone. If native code owns what the user is viewing or editing, it is not brought into completed Avalonia regions. If native code supplies custom linguistics capability that supports FieldWorks' role in documenting many languages, it may remain behind an explicit service seam. + +- **Native Views layout/render/editing:** `VwRootBox`, `IVwEnv`, `IRenderEngine`, selections, hit testing, `OnTyping`, `OnExtendedKey`, table layout, interlinear layout, and RootSite editing are not mere windowing. They are the render/editor pipeline and remain a hard removal gate for migrated regions. +- **Custom linguistics services:** XAmple, spelling, parser/conversion engines, ICU, Encoding Converters, and similar language-documentation capabilities are allowed to remain in C++ or native/external form when invoked through managed service boundaries. Avalonia may consume their results, but it must not depend on their UI, rendering, or RootBox integration. +- **Spell-check interop:** `RootSite` wires `SetSpellingRepository(IGetSpellChecker)` into `VwRootBox`, while managed helpers build spelling context menus. Avalonia can keep spelling as a service, but any dependency on RootBox spell integration must be replaced for migrated regions. +- **Parser/conversion/native utility tools:** `pcpatr64.exe`, `TonePars64.exe`, `xample.dll`, Encoding Converter native files, ICU artifacts, Expat/ParserObject, and reg-free COM/proxy/stub build infrastructure are real native dependencies. They are not default Lexical Edit viewing dependencies, but migrated workflows that invoke them must wrap them as services and keep them outside Avalonia rendering/editor completion gates. + +## Interface Direction + +Early seams should stay narrow and name the FieldWorks domain they protect: + +- `ILexicalRefreshCoordinator` for refresh/postponed `PropChanged` behavior. +- `IViewDefinitionSource`, `IXmlViewDefinitionImporter`, `IViewDefinitionCompiler`, `IViewDefinitionCache`, and `IViewDefinitionDiagnostics` for the XML-to-typed transition. +- Proposed `ILexicalEditorRegistry`, `EditorDescriptor`, and `ILexicalEditorFactory` boundaries for resolving legacy slices now and future Avalonia editors later. +- Proposed `IEditSession` or `IEditTransactionCoordinator` boundaries for LCModel transactions, validation, cancellation, undo/redo grouping, and dirty-state command enablement. The current AdvancedEntry code uses a concrete fenced edit session. +- Proposed `IXCoreCommandBridge`, `IPropertyStateStore`, `IRecordNavigationContext`, `IUiScheduler`, `IFocusNavigationService`, and `IRegionLifetime` boundaries for current xCore/DataTree behaviors that must not be hidden inside a single broad context object. +- Feature-specific custom linguistics ports such as `ISpellingService`, `IMorphParserService`, and `IEncodingConversionService` only when a migrated editor actually needs them. + +## Architecture Diagrams + +See [architecture-diagrams.md](architecture-diagrams.md) for Mermaid diagrams covering the current WinForms/DataTree architecture, MVC pressure, dependency-inversion seams, testing layers, optional first Avalonia slices, table/full Lexical Edit slices, and the final audited Avalonia default architecture. + +See [seam-recommendations.md](seam-recommendations.md) for the accepted seam recommendations, the three options compared for each seam, references used, and the pivot triggers that would justify changing direction later. + +## Refactoring Split Options + +### Option A: Safety-first legacy seams + +Split by existing risk: test coverage and docs, refresh/DataTree services, launcher humble objects, editor registry, semantic render capture, then Avalonia controls. + +**Best for:** Regression safety and small PRs. +**Trade-off:** Slower time to visible Avalonia progress. + +### Option B: Contract-first migration + +Split by the new architecture: view-definition schema, XML importer, IR compiler/cache, semantic parity harness, then Avalonia renderer/editor registry. + +**Best for:** Fast progress on XML retirement and future non-XML definitions. +**Trade-off:** More risk if DataTree refresh/launcher seams are not stabilized first. + +### Option C: Vertical thin slice + +Pick a representative lexical path, such as LexEntry morph type plus nested sense gloss and one popup chooser: baseline legacy, compile XML to IR, render/edit in Avalonia Headless, compare semantic/pixel artifacts. + +**Best for:** Proving the end state early and exposing framework gaps. +**Trade-off:** Leaves broad legacy debt in place and can tempt ad hoc special cases. + +**Recommendation:** Use Option A for the first two refactor PRs, then Option C for the first full Avalonia slice, while building the typed view-definition pieces from Option B as shared infrastructure. + +## Risks / Trade-offs + +- XML import drift from legacy behavior -> Mitigate with semantic snapshots and parity tests against production layouts and user-override fixtures. +- Refresh protocol regressions -> Extract/cover refresh coordination before UI replacement. +- TreeView/table complexity -> Spike dense custom item templates, TreeDataGrid license/version implications, and owned virtualized row templates early. +- Graphite/native rendering decommissioning -> Begin the inventory at migration start and block Avalonia default until Graphite engines, feature UI/storage, sample fonts, Gecko Graphite prefs, PDF/export assumptions, and tests/docs are classified, replaced, moved behind legacy boundaries, or blocked with diagnostics. +- Gecko/browser rendering -> `XWebBrowser`, dictionary/configuration previews, interlinear configuration previews, print, and `GeckofxHtmlToPdf` need a non-Graphite replacement or an explicit non-default legacy boundary. +- PropertyGrid limits -> Treat it as a prototype path; do not let it define the final IR or UI shape. +- Automation flakiness -> Keep UIA2 tests thin; use model/semantic assertions for deep behavior. +- XML retirement too early -> Gate deletion on migration tooling, custom-field coverage, user overrides, ghost behavior, chooser parity, and fallback ability. +- C++ viewing/rendering removal exposes text/layout gaps -> Gate each region on dependency audits and replacement services for text shaping, selection, measurement, hit testing, scrolling, and printing/export behaviors where applicable. Do not count custom linguistics service calls as blockers unless they own UI viewing/editing behavior. +- Over-broad interfaces -> Prefer small domain ports proven by legacy characterization tests; avoid a single “context” service that preserves DataTree/Mediator/PropertyTable coupling. +- Undo/redo, focus, keyboard/IME, and lifecycle regressions -> Treat edit sessions, command routing, UI dispatch, focus restoration, and disposal/unsubscribe behavior as first-slice gates, not cleanup work. +- Typed IR becomes a second UI framework -> Version the core view definition, keep instance presentation state separate, and add diagnostics for behavior the IR cannot express yet. + +## Migration Plan + +1. Freeze current behavior with targeted unit/integration/render/UIA2 baselines, including undo/redo, focus, keyboard/IME, accessibility, localization, customer overrides, and disposal behavior. +2. Introduce DI-friendly services around DataTree refresh, view-definition source/import/compile/cache, editor selection, command/property/navigation state, edit sessions, UI dispatch, lifetime, LCModel access, and launcher logic, following `avalonia-ui-scheduler`, `avalonia-lifetime`, and the local phase of `avalonia-command-focus`. +3. Start Graphite/native rendering decommissioning: inventory affected project settings, fonts, render engines, Gecko/PDF paths, tests, docs, and build artifacts; prove no default-path claim depends on unverified Graphite behavior. +4. Define migrated-region manifests and hard gates for each proposed Avalonia region. +5. Extend render verification with normalized semantic snapshots, visual/timing evidence, performance budgets, and failure bundles. +6. Build typed view-definition and XML import as the compatibility compiler. +7. Replace text foundation, simple controls, edit sessions, validation, undo/redo routing, and hover/popups in Avalonia using owned editor controls, following `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local phase of `avalonia-command-focus`. +8. Replace table/browse views with virtualized Avalonia table/tree structures. +9. Replace slices and full Lexical Edit views with Avalonia surfaces over the typed contract. +10. Audit the migrated region's runtime call graph and remove/disable native viewing/rendering/editor dependencies for that region, while classifying custom linguistics engines as service seams when they do not own the Avalonia UI surface. +11. Add managed canonical view-definition authoring and migration tooling. +12. Retire runtime XML only after parity gates pass for production layouts, custom fields, user overrides, dynamic editors, unsupported constructs, and fallback behavior. +13. Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` once Lexical Edit regional seams are proven. + +## Open Questions + +1. Should the canonical post-XML view-definition format be C# builders, JSON/YAML, resources, database-backed project settings, or a hybrid? +2. Which shipped/sample/customer fonts and writing systems require replacement or migration because they depend on Graphite-only shaping or feature IDs? +3. Is `TreeDataGrid` acceptable for any Lexical Edit surface given package/licensing/version constraints, or should FieldWorks own all dense tree/table rows? +4. Which customer layout override fixtures should become mandatory migration tests? +5. Which non-Lexical Edit consumers keep native Views alive after Lexical Edit regions are migrated, and what is the repo-wide deletion plan once those consumers are addressed? +6. Which browser/PDF engine or legacy boundary will own XHTML preview, print, and PDF behavior after default Lexical Edit moves away from Gecko/Graphite assumptions? diff --git a/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md b/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md new file mode 100644 index 0000000000..fdcd9d6083 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md @@ -0,0 +1,68 @@ +# Graphite and Native Rendering Decommissioning Plan + +This plan treats Graphite and native Views rendering as a migration risk, not as a solved problem. The key correction from re-research: Avalonia using Skia/HarfBuzz does not automatically prove Graphite parity. HarfBuzz Graphite2 shaping is optional and its own documentation says Graphite2 support is currently not enabled by default when building HarfBuzz. + +## 1. Current Repo Inventory + +| Area | Source / Symbol | Role | Migration Classification | +|---|---|---|---| +| Native Graphite render engine | [Src/views/lib/GraphiteEngine.cpp](Src/views/lib/GraphiteEngine.cpp), `GraphiteEngineClass`, `FwGrEngine`, `GraphiteSegment` | Current native Views Graphite shaping/rendering path. | Default-path blocker until Avalonia text shaping evidence exists. | +| Native Graphite segmenting | [Src/views/lib/GraphiteSegment.cpp](Src/views/lib/GraphiteSegment.cpp), [Src/views/lib/GraphiteSegment.h](Src/views/lib/GraphiteSegment.h) | Segment data, glyph metrics, and render behavior. | Baseline source for parity fixtures. | +| Render factory | [Src/Common/FwUtils/RenderEngineFactory.cs](Src/Common/FwUtils/RenderEngineFactory.cs) | Chooses Graphite vs Uniscribe render engines based on writing-system/font configuration. | Hidden dependency audit target. | +| COM/native interfaces | [Src/views/lib/Render.idh](Src/views/lib/Render.idh), `IRenderEngine`, `IRenderEngineFactory` | COM interfaces for native rendering. | Forbidden from migrated Avalonia default path unless intentionally bridged and documented. | +| Writing-system storage | `IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite` usages across LCModel/Common | Stores project/language rendering preferences and feature strings. | Must be preserved in snapshots or diagnostics. | +| Browser/PDF paths | `GeckofxHtmlToPdf`, `FieldWorksPdfMaker`, PDF `--graphite` flags, XHTML preview/export | Non-edit rendering/export surfaces may still require legacy Graphite behavior. | Separate from Lexical Edit default path; do not remove during first migration. | +| Packaging/build | Graphite2/HarfBuzz native libraries under build/package scripts | Determines which shaping libraries are present. | Packaging audit required before any claim of parity. | + +## 2. External Documentation Findings + +- HarfBuzz building docs: Graphite2 support is controlled by the build option `-Dgraphite=enabled`; the default is not enabled. +- HarfBuzz Graphite2 integration docs: Graphite features work only when HarfBuzz was compiled with the Graphite2 shaping engine enabled. +- Avalonia font docs: Avalonia supports TrueType/OpenType custom fonts and OpenType `FontFeatures`, but the docs do not establish FieldWorks Graphite feature parity. + +Conclusion: the migration may use Avalonia text rendering for many scripts, but Graphite parity must be proven with actual fonts, writing-system metadata, and packaged native shaping support. + +## 3. Default-Path Policy + +The migrated Lexical Edit default path MUST NOT depend on these symbols unless a specific exception is approved and tested: + +- `System.Windows.Forms.Control`, `DataTree`, `Slice`, `RootSiteControl`, `XmlView`, `BrowseViewer`. +- `IVwRootBox`, `IVwEnv`, `IVwGraphics`, `IRenderEngine`, `IRenderEngineFactory`. +- `GraphiteEngineClass`, `UniscribeEngineClass`, `FwGrEngine`, `GraphiteSegment`. +- `GeckoWebBrowser`, `XWebBrowser`, `GeckofxHtmlToPdf`, `FieldWorksPdfMaker`. +- Global COM registration or registry hacks. + +Legacy code may remain for non-migrated views, preview/export, tests, and rollback. The policy applies only to the new migrated default path. + +## 4. Required Evidence Before Decommissioning + +| Evidence | Required Checks | +|---|---| +| Repository audit | Search default-path projects for forbidden symbols; document any allowed baseline/test-only references. | +| Packaging audit | Confirm the exact HarfBuzz/Skia/native library build includes or excludes Graphite2 support; record the binary/source evidence. | +| LDML fixture scan | Identify writing systems with `IsGraphiteEnabled`, `DefaultFontFeatures`, Graphite-only features, right-to-left scripts, complex scripts, and custom fonts. | +| Rendering fixtures | Capture representative words/forms for Graphite-enabled fonts and compare legacy screenshot/metrics with Avalonia output where feasible. | +| Fallback behavior | For unsupported Graphite features, produce visible diagnostics and a rollback path rather than silently changing rendering. | +| Browser/PDF decision | Classify each browser/PDF/export path as legacy-retained, migrated later, or out of scope. | + +## 5. Phased Plan + +| Phase | Work | Exit Gate | +|---|---|---| +| Phase 1 inventory | Complete source and symbol inventory for native Views, Graphite, browser/PDF, writing-system metadata, and package inputs. | Inventory has source-backed entries and no unsupported Avalonia/HarfBuzz assumptions. | +| Phase 2 characterization | Add fixtures for Graphite-enabled writing systems and text samples, plus current native/default path coverage report. | Fixtures identify which scripts/fonts are safe, risky, or blocked. | +| Phase 3-5 first-slice migration | Keep Graphite and native Views as legacy fallback while the first Avalonia editor uses only proven text paths. | Default path forbidden-symbol audit passes or lists explicit approved exceptions. | +| Phase 6-8 parity expansion | Add measured Avalonia rendering evidence for complex scripts, IME, RTL, and Graphite feature scenarios. | User-visible rendering differences are accepted, fixed, or blocked with rollback. | +| Phase 9 retirement | Remove or disable a legacy rendering dependency only after all consumers are classified. | Browser/PDF/export and rollback owners sign off; full build/test/package checks pass. | + +## 6. Test Matrix + +| Scenario | Minimum Test | +|---|---| +| Graphite-enabled WS with feature string | Fixture loads WS metadata, text sample renders, and diagnostics record Graphite capability. | +| OpenType-only font features | Avalonia `FontFeatures` path preserves documented OpenType features where supported. | +| Graphite-required feature unsupported | UI exposes deterministic diagnostic and keeps rollback/default-off path. | +| Mixed writing systems | Per-run and per-span metadata is preserved in the typed presentation snapshot. | +| Packaging drift | CI or agent script records native shaping library versions/options for the build under test. | + +This plan intentionally avoids promising Graphite decommissioning as part of the first editable slice. The first milestone is knowing exactly where Graphite matters and preventing silent rendering regressions. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/migration-map.md b/openspec/changes/lexical-edit-avalonia-migration/migration-map.md new file mode 100644 index 0000000000..eb67652efb --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/migration-map.md @@ -0,0 +1,15 @@ +# Migration Map: Speckit Advanced Entry to OpenSpec Lexical Edit + +This change migrates the useful Speckit material from `specs/010-advanced-entry-view/` into OpenSpec while expanding scope from Advanced New Entry to the full Lexical Edit Avalonia migration. + +| Speckit source | OpenSpec destination | Notes | +|---|---|---| +| `spec.md` | `proposal.md`, `specs/lexical-edit-avalonia-migration/spec.md` | Reframed from Advanced New Entry to full Lexical Edit migration. Existing FR/SC ideas become phased migration requirements and acceptance gates. | +| `plan.md` | `design.md`, `tasks.md` | Carries over Path 3, Preview Host, cache/async/virtualization, and headless testing, but changes XML from long-term contract to transitional import source. | +| `research.md` | `design.md`, `specs/lexical-edit-view-definition/spec.md` | Preserves Avalonia/.NET 8, diagnostics, validation, and IR direction. | +| `presentation-ir-research.md` | `design.md`, `specs/lexical-edit-view-definition/spec.md` | Preserves Inventory/LayoutCache/XMLViews reuse research and adds XML retirement gates. | +| `parity-lcmodel-ui.md` | `specs/lexical-edit-parity-automation/spec.md`, `tasks.md` | Becomes the baseline checklist for semantic parity scenarios. | +| `tasks.md` | `tasks.md` | Existing completed Advanced Entry spike tasks are treated as prior art; new tasks sequence refactor-first Lexical Edit migration. | +| `quickstart.md` | Future implementation quickstart | Not copied yet because this OpenSpec change is architectural and phased. | +| `data-model.md` | Future typed view-definition data model | Not copied verbatim; its concepts feed the IR/view-definition requirements. | +| `contracts/openapi.yaml` | Not migrated | Internal API contract is too narrow for the broader Lexical Edit architecture. Revisit if a service boundary becomes externally callable. | diff --git a/openspec/changes/lexical-edit-avalonia-migration/override-fixtures.md b/openspec/changes/lexical-edit-avalonia-migration/override-fixtures.md new file mode 100644 index 0000000000..18c5b1eab1 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/override-fixtures.md @@ -0,0 +1,67 @@ +# Customer and User Override XML Fixtures + +This document defines the fixture plan for preserving FieldWorks project/user layout overrides while migrating Lexical Edit toward typed view definitions and Avalonia controls. It is a test plan, not just an inventory. + +## 1. Override Sources to Preserve + +| Source | Examples / Paths | Why It Matters | +|---|---|---| +| Shipped detail layouts | `DistFiles/Language Explorer/Configuration/Parts/*.fwlayout`, `*Parts.xml` | Baseline LexEntry/LexSense/Morphology behavior, labels, visibility, ghost slices, and custom-field placeholders. | +| Project dictionary configs | `/Configuration/Dictionary/*.xml` | User-edited dictionary publication layouts, columns, before/between/after text, writing-system options, list options, and shared nodes. | +| Project reversal configs | `/Configuration/ReversalIndex/*.xml` | Reversal-specific labels, headword/gloss options, writing-system choices, and migrated historical config variants. | +| Historical configs | `PreHistoricMigrator`, `FirstAlphaMigrator`, `FirstBetaMigrator`, and related xWorks tests | Old projects must migrate into the typed-definition path without losing custom choices. | +| CSS overrides | `ProjectDictionaryOverrides.css`, `ProjectReversalOverrides.css` | Legacy preview/export styling. The migrated edit surface must explicitly classify this as legacy-only, translated, or unsupported with diagnostics. | +| Writing-system/font metadata | LDML writing-system store, `IsGraphiteEnabled`, `DefaultFontFeatures` | Layout and rendering behavior can change when writing-system and font metadata are ignored. | + +## 2. Concrete Fixture Families + +Each family must have input files, expected typed IR or diagnostics, and mismatch artifacts. + +| Fixture Family | Minimum Cases | Expected Assertions | +|---|---|---| +| Shipped `LexEntry-detail-Normal` | Top-level identity fields, lexeme form, citation form, pronunciations, senses | Stable node IDs, class/flid binding, editor kind, visibility, focus order, accessibility ID/name. | +| Nested senses and ghost entries | Senses with subsenses, examples, ghost labels/init methods | Ghost metadata and lazy item templates survive compilation without recursive expansion loops. | +| Custom fields | Entry/sense/allomorph custom scalar, multistring, possibility-list fields | Custom field placeholders resolve deterministically and retain flid/type/writing-system metadata. | +| Dictionary configuration migration | Current, pre-8.3, alpha, beta-style dictionary configs from xWorks tests | Migrator output remains schema-valid and typed-definition import preserves order and labels. | +| Reversal configuration migration | Reversal language variants, subentries, missing or invalid reversal writing systems | Reversal-specific labels and writing-system options are preserved or diagnosed. | +| Duplicate/shared nodes | Shared senses, referenced complex forms, duplicate custom nodes | Stable node IDs remain unique; referenced nodes do not duplicate children accidentally. | +| CSS/browser styling | Dictionary and reversal CSS override samples | Classified as legacy preview/export, converted to Avalonia resources, or reported as unsupported with a diagnostic. | + +## 3. Compiler and Merger Strategy + +The typed-definition compiler must consume an immutable snapshot of fully merged configuration data. It must not read directly from WinForms controls, live `PropertyTable` mutation state, or mutable `LcmCache` objects on background threads. + +Required pipeline: + +1. Load shipped parts/layouts. +2. Apply project/user override precedence using the same semantics as `LayoutCache`/`Inventory` and dictionary migrators. +3. Convert the merged XML to typed view definitions / Presentation IR. +4. Normalize stable comparison keys: node ID, class/flid/object binding, editor kind, writing-system metadata, visibility, ghost metadata, focus order, and accessibility identity. +5. Emit unsupported-construct diagnostics with source file, layout ID, part ref, XML path, and suggested owner. + +## 4. Test and Artifact Requirements + +| Test Type | Required Evidence | +|---|---| +| Snapshot tests | Expected JSON committed for selected fixtures; actual JSON attached on mismatch. | +| Migration tests | Existing xWorks migrator tests remain green; selected fixtures run through the AdvancedEntry typed-definition compiler. | +| Override precedence tests | Same layout/part ID in shipped and project config proves project override wins. | +| Unsupported construct tests | Dynamic/custom editor constructs produce deterministic diagnostics instead of silent drops. | +| Localization tests | User-visible labels come from layout/resource metadata and remain localizable; no new hardcoded production strings. | +| Writing-system tests | Writing-system options, font metadata, direction/culture, and missing WS cases are captured or diagnosed. | + +## 5. Phasing + +| Phase | Goal | Exit Criteria | +|---|---|---| +| Phase 1 inventory | Select fixture families and map existing migrator/layout tests. | Every fixture has source path, owner, behavior being protected, and known current test coverage. | +| Phase 2 characterization | Add semantic baselines before refactor. | DataTree/Slice and Avalonia IR baselines pass; unsupported gaps are documented. | +| Phase 4 typed import | Implement merged XML to typed-definition compiler. | Fixtures compile deterministically or emit approved diagnostics; cache invalidation/cancellation tests pass. | +| Phase 9 retirement | Disable runtime XML for a gated migrated surface. | Import/audit fallback exists, customer override fixtures pass, rollback switch remains available. | + +## 6. Non-Goals for First Slice + +- Do not translate arbitrary user CSS into full Avalonia styling until a real styling compiler is designed. +- Do not delete XML layouts while legacy DataTree/XMLViews still use them. +- Do not claim UIA2 parity without a real UI automation harness. +- Do not treat Graphite-only font settings as harmless; preserve or diagnose them until the rendering policy is proven. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md b/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md new file mode 100644 index 0000000000..f49f351727 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md @@ -0,0 +1,113 @@ +# Phase 2 Test Coverage Report + +This is a behavioral coverage report for the Phase 2 "Test Coverage Before Refactor" gate of `lexical-edit-avalonia-migration`. It is not a line-coverage percentage. The goal is to show which Phase 3 refactor surfaces now have executable characterization tests, where the tests live, and which gaps remain too large or too infrastructural to mark complete honestly. + +This narrowed Phase 1/2 branch keeps OpenSpec planning plus legacy WinForms/DataTree/XMLViews characterization coverage. It now also contains the net48 `FwAvalonia` spike, a net48 `FwAvaloniaPreviewHost`, and `System.Windows.Automation` UIA smoke tests for the preview host. The older net8 `AdvancedEntry.Avalonia` prototype and its net8-specific host/test bootstrap remain split to `010-advanced-entry-preview-prototype`. Product command/menu wiring has been split to `010-advanced-entry-product-launcher-spike`. The unrelated `RecordList` sorting change was dropped from this scope. + +--- + +## 1. Subagent Audit Result + +Three read-only subagents audited the Phase 3 refactor surfaces before new tests were added: + +- **DataTree refresh and hosting**: found real coverage for LT-22414 but gaps around refresh cancellation, focus order, and nested refresh semantics. +- **Launcher and chooser logic**: found coverage for DataTree refresh after morph swaps, but almost no pure coverage for morph-type classification, chooser cancel/OK paths, data-loss prompts, or SliceFactory fallback behavior. +- **Avalonia edit/session and IR seams**: audited as future/prototype coverage. The implementation and net8 tests now live on `010-advanced-entry-preview-prototype`, not this foundation branch. + +The tests below are the new coverage added from that audit. + +--- + +## 2. Tests Added In This Pass + +### Legacy DetailControls / WinForms Boundary + +- `DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh` + - Pins the cancellation behavior of `DataTree.DoNotRefresh` + `RefreshListNeeded` before extracting refresh coordination. +- `IsStemType_*_ReturnsTrue/False` and `IsStemType_NullMorphType_ReturnsFalse` + - Covers the full known stem-like and affix-like `MoMorphTypeTags` GUID matrix before morph-type swap logic is extracted. +- `CheckForStemDataLoss_EmptyStemAndNoMorphSyntaxAnalyses_AllowsChangeWithoutPrompt` and `CheckForAffixDataLoss_EmptyAffixAndNoMorphSyntaxAnalyses_AllowsChangeWithoutPrompt` + - Pin the non-modal no-data-loss branches before extracting prompt decisions from `MorphTypeAtomicLauncher`. +- `GetStemDataLossKinds_StemNameAndGrammarInfo_FlagsBoth` + - Extracts and pins the pure stem-side data-loss classifier for stem-name and grammatical-information loss before modal prompt extraction. +- `GetAffixDataLossKinds_AffixProcessWithInflectionClassAndGrammarInfo_FlagsRuleInflectionClassAndGrammarInfo` + - Pins the affix-process rule, inflection-class, and grammatical-information loss combination without invoking `MessageBox.Show`. +- `GetAffixDataLossKinds_AffixAllomorphWithPositionAndMsEnv_FlagsInfixLocationAndGrammarInfo` + - Pins the affix-allomorph infix-position and morphosyntactic-environment loss combination. +- `LauncherButtonClick_WithValidObject_ReachesChooserDecisionPath` + - Provides a focused WinForms launcher smoke baseline proving a valid launcher button click reaches the chooser decision path without opening the modal chooser. +- `Create_UnknownEditor_ReturnsMessageSliceWithEditorAccessibleName` + - Pins `SliceFactory` fallback behavior before adding an editor registry boundary. +- `CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` + - Captures legacy DataTree/Slice output order, labels, object/class binding, field/flid binding, editor kind, visibility, expansion state, and accessible control names before DataTree/Slice replacement work. +- `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` + - Adds an in-repo XMLViews smoke baseline for browse-table header order and filter reachability (`Lexeme Form` filter-for path and `Morph Type` chooser filter path) without adding a new UIA2 dependency. + +### Split Avalonia Prototype Boundary + +The following coverage belongs to `010-advanced-entry-preview-prototype`, not this branch: edit-session save/cancel tests, Presentation IR snapshot tests, descriptor metadata tests, validation determinism tests, Avalonia view-model lifetime tests, and snapshot failure artifacts. This branch keeps the plan and identifies those seams, but does not claim the prototype implementation as Phase 1/2 foundation evidence. + +--- + +## 3. Phase 2 Task Status + +| Task | Status | Evidence | Remaining Gap | +| :--- | :--- | :--- | :--- | +| 2.1 DataTree refresh state transitions and postponed `PropChanged` behavior | Covered | `MorphTypeAtomicLauncherTests`: LT-22414 tests plus `DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh` | Nested `DoNotRefresh` semantics are still a design question for Phase 3 extraction. | +| 2.2 Launcher pure-logic tests, morph type swap, chooser paths | Covered for Phase 2 | Full `IsStemType` matrix; no-data-loss checks; pure positive data-loss classifiers; launcher click smoke path; morph swap refresh regression tests | Full modal OK/Cancel chooser-result handling remains a Phase 3 seam-extraction target. | +| 2.3 Semantic baseline capture | Partially covered for legacy boundary | `DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` | Typed IR/snapshot normalization moved to the preview prototype branch; ghost-state and override fixture coverage remain future work. | +| 2.4 Focused UIA2 smoke baselines | Not complete; smoke substitute only | `LauncherButtonClick_WithValidObject_ReachesChooserDecisionPath`; `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` | Full UIA2/FlaUI/Appium parity harness remains future work and must not be implied by these in-repo smoke tests. | +| 2.5 Failure artifact bundling | Not covered in this branch | None in the narrowed foundation branch | Snapshot/render artifact bundling moved with the prototype and still needs render-parity evidence later. | +| 2.6 Undo/redo and LCModel transaction characterization | Not covered in this branch | None in the narrowed foundation branch | Edit-session and commit-fence characterization moved to `010-advanced-entry-preview-prototype`. | +| 2.7 Keyboard/IME, focus restoration, accessibility metadata, localization, disposal/unsubscribe | Not covered in this branch | None in the narrowed foundation branch | True text-editor IME, popup focus restoration, accessibility metadata, localization, and disposal coverage remain future/prototype work. | +| 2.8 Snapshot normalization rules | Not covered in this branch | None in the narrowed foundation branch | Normalized Presentation IR snapshots moved to `010-advanced-entry-preview-prototype`; Phase 4 still needs first-class class/flid/object/writing-system metadata. | + +--- + +## 4. Phase 3 Refactor File Coverage + +| Refactor Surface | Files Expected To Change | Current Characterization Tests | Coverage Confidence | +| :--- | :--- | :--- | :--- | +| Refresh coordination (`ILexicalRefreshCoordinator`) | `Src/Common/Controls/DetailControls/DataTree.cs` | `DoNotRefresh_SlicesMustReflectChanges_AfterRelease_LT22414`, `DoNotRefresh_WithoutRefreshListNeeded_DoesNotRefresh_LT22414_BugDemo`, `DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh`, `CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` | Medium. Basic gate behavior and stable slice output are covered; nested/re-entrant behavior is intentionally not locked down yet. | +| Launcher humble object extraction | `Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs` | Full `IsStemType` matrix; pure positive data-loss classifiers; no-data-loss checks; launcher click smoke path; LT-22414 swap refresh tests | Medium/High for pre-seam logic. Modal result interpretation still needs extraction before direct OK/Cancel unit tests. | +| Editor registry boundary | `Src/Common/Controls/DetailControls/SliceFactory.cs` | `SetConfigurationDisplayPropertyIfNeeded_Works`, `Create_UnknownEditor_ReturnsMessageSliceWithEditorAccessibleName` | Low/Medium. Fallback and custom display-property behavior are pinned; common editor dispatch and reuse-map compatibility need more tests before broad registry rewrites. | +| Edit-session and LCModel transaction seam | Future `AdvancedEntry`/edit-session targets | Split to `010-advanced-entry-preview-prototype` | Not foundation-branch evidence. | +| Typed IR and snapshot normalization | Future `PresentationCompiler`/IR targets | Split to `010-advanced-entry-preview-prototype` | Not foundation-branch evidence. | +| Property-grid first-slice candidates | Future property-grid/editor targets | Split to `010-advanced-entry-preview-prototype` | Not foundation-branch evidence. | +| Validation seam | Future validation service targets | Split to `010-advanced-entry-preview-prototype` | Not foundation-branch evidence. | +| Native library/bootstrap for headless tests | Future net8 test bootstrap targets | Split to `010-advanced-entry-preview-prototype` | Not foundation-branch evidence. | + +--- + +## 5. Verification Commands + +```powershell +.\test.ps1 -TestProject "Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj" +``` + +Result: 88 passed, 1 skipped, 0 failed. +Current Phase 2 rerun: 93 total, 92 passed, 1 skipped, 0 failed. + +```powershell +.\test.ps1 -TestProject "Src/xWorks/xWorksTests/xWorksTests.csproj" +``` + +Current Phase 2 rerun: 1202 passed, 0 failed. + +```powershell +.\test.ps1 +``` + +Earlier broad-branch rerun before the split: 4329 total, 4253 executed/passed, 0 failed. This should be rerun after the narrowed foundation branch is committed. + +```powershell +git diff --check +``` + +Result: no whitespace errors in the current diff. + +```powershell +openspec validate lexical-edit-avalonia-migration --strict +``` + +Result: change is valid. diff --git a/openspec/changes/lexical-edit-avalonia-migration/proposal.md b/openspec/changes/lexical-edit-avalonia-migration/proposal.md new file mode 100644 index 0000000000..68197fb197 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/proposal.md @@ -0,0 +1,57 @@ +## Why + +Lexical Edit is the main editing surface in FLEx, but its current WinForms/DataTree/XMLViews architecture mixes view definition, control creation, LCModel access, refresh state, and legacy rendering concerns in ways that make the Avalonia migration risky. The existing Advanced Entry Speckit work proves useful pieces of an Avalonia path, but the broader migration needs an OpenSpec plan that treats XML Parts/Layout as a transitional compatibility contract and makes testability/refactoring the first-class work before replacing UI. + +Current branch scope: this Phase 1/2 foundation branch contains OpenSpec planning, migration-review skills, legacy WinForms/DataTree/XMLViews characterization coverage, the net48 `FwAvalonia` spike, and a net48 `FwAvaloniaPreviewHost` + preview-host UIA harness. The older net8 `AdvancedEntry.Avalonia` prototype remains on `010-advanced-entry-preview-prototype`; product command/menu wiring is split to `010-advanced-entry-product-launcher-spike`; the unrelated `RecordList` sorting change was dropped. + +## What Changes + +- Migrate the Advanced Entry Speckit research, parity checklist, and task intent into OpenSpec under a broader Lexical Edit migration change. +- Establish a phased migration contract: baseline tests first, legacy refactoring seams second, then Avalonia simple controls/popups, table views, slices, and full Lexical Edit views. +- Introduce a typed, managed view-definition/Presentation IR as the migration boundary. Existing XML Parts/Layout remains an import source during transition; long-term runtime XML dependency is retired only after parity is proven. +- Make native viewing/rendering decommissioning a completion gate for each migrated region: if native code owns display, layout, measurement, hit testing, selection, or editor realization, it SHALL NOT be brought into the completed Avalonia region. Custom linguistics engines and native services such as XAmple, spelling, parser/conversion tools, or similar language-documentation capability may remain when isolated behind service seams outside the Avalonia render/editor path. +- Start Graphite/native-rendering decommissioning with the migration. Avalonia SHALL NOT become the default Lexical Edit screen until Graphite font settings, native Graphite engines, Gecko Graphite rendering, PDF/export assumptions, tests, docs, and build/package artifacts are inventoried, classified, and either replaced, retained behind a legacy boundary, or blocked with explicit diagnostics and rollback. +- Require dependency-injected services around DataTree/Slice/Launcher behavior, view-definition source/import/compile/cache, editor selection, edit sessions, LCModel transactions, undo/redo grouping, validation, command/focus routing, UI dispatch, lifetime/disposal, diagnostics, and render/parity capture. +- Freeze seam-specific recommendations in dedicated capability specs for edit sessions, undo/redo, validation, command/focus, UI scheduling, and lifetime so phase-one lexical work and phase-two shell work consume the same decisions. +- Define migrated-region manifests and hard gates so each claimed Avalonia region has explicit entry points, allowed legacy adapters, forbidden native/Graphite call paths, custom linguistics service dependencies, parity fixtures, performance budgets, and rollback/default-switch rules. +- Extend render verification from pixel/timing snapshots to semantic parity snapshots covering legacy WinForms/DataTree, typed IR, and Avalonia output. +- Define automation strategy: UIA2/FlaUI-style tests for legacy WinForms workflow reachability; Avalonia.Headless tests for new controls; layered unit/integration tests for IR, LCModel, refresh, and transactions. +- Allow Avalonia package updates or targeted upstream/local control work when stock controls cannot preserve FieldWorks density, interaction semantics, OpenType/HarfBuzz text, or TreeView requirements. + +## Non-goals + +- Replacing the full Lexical Edit UI in one change. +- Removing XML Parts/Layout before the typed IR and migration tooling can prove parity for user overrides, custom fields, ghost items, choosers, and nested sequences. +- Replacing LCModel or changing stored lexicon data schemas. +- Treating pixel-perfect WinForms output as the target. The target is near-pixel parity with equivalent information density, font/script behavior, interaction semantics, and accessibility. +- Deleting the global native Views engine before all non-migrated consumers are accounted for. Native rendering may remain as a legacy baseline or for other regions during transition, but it is not acceptable in completed Avalonia Lexical Edit regions. + +## Capabilities + +### New Capabilities + +- `lexical-edit-avalonia-migration`: End-to-end phased migration requirements for Lexical Edit from WinForms/DataTree/XMLViews toward Avalonia. +- `lexical-edit-view-definition`: Typed view-definition and Presentation IR requirements, including XML import during transition, dynamic editor diagnostics, stable identity, virtualization/focus metadata, and XML retirement gates. +- `lexical-edit-parity-automation`: Test, UI automation, render verification, and semantic parity requirements for WinForms and Avalonia migration safety. +- `lexical-edit-font-decommissioning`: Graphite/native rendering classification, OpenType/HarfBuzz font-option migration where supported, Gecko/browser/PDF impact, and native dependency requirements. +- `avalonia-edit-sessions`: FieldWorks-owned edit-session and commit-boundary requirements for editable Avalonia regions, starting from the current direct LCModel fenced-session model. +- `avalonia-undo-redo`: Domain-authoritative undo/redo requirements with control-local leaf undo allowed only as a subordinate behavior. +- `avalonia-validation`: FieldWorks-owned validation seam requirements with Avalonia-native presentation and package-backed rule engines as subordinate options. +- `avalonia-command-focus`: Global command/focus bridge requirements for shell and popup behavior, while allowing screen-local Avalonia commands inside migrated regions. +- `avalonia-ui-scheduler`: Thin UI-thread scheduling seam requirements for non-view layers with direct Avalonia dispatcher use allowed only at the view and startup edge. +- `avalonia-lifetime`: Thin app/window/dialog lifetime seam requirements for non-view layers, with heavier region frameworks explicitly deferred. + +### Modified Capabilities + +- `architecture/ui-framework/views-rendering`: Add semantic parity capture and Avalonia comparison requirements to existing render baseline guidance. +- `architecture/ui-framework/winforms-patterns`: Add DetailControls/DataTree refactoring and UIA2 baseline expectations for legacy WinForms surfaces. +- `architecture/interop/native-boundary`: Add the requirement that migrated Avalonia regions eliminate runtime managed-to-native render interop. +- `architecture/testing/test-strategy`: Add layered UI migration testing expectations using unit/integration tests, UIA2 for WinForms, and Avalonia.Headless for Avalonia. + +## Impact + +- Managed code: `Src/Common/Controls/DetailControls/`, `Src/Common/Controls/XMLViews/`, `Src/xWorks/`, `Src/LexText/`, future/split `Src/LexText/AdvancedEntry.Avalonia/`, future/split `Src/Common/FwAvalonia/`, `Src/Common/RenderVerification/`, and related managed test projects. +- Native code: no native viewing/rendering path is planned for completed Avalonia regions. Existing Views/native rendering remains in baseline and comparison scope until replaced, but the dependency audit for each migrated region must prove there is no runtime call path through native display, layout, measurement, hit testing, selection, or editor-realization code before that region is considered complete. Native custom linguistics services that support FieldWorks' language-documentation mission, such as XAmple, spelling, parser/conversion tools, ICU, or Encoding Converters, may remain as explicit service dependencies when kept outside the Avalonia render/editor boundary. Graphite native code and render-engine selection are explicitly in inventory/decommissioning scope for the migrated default path, while legacy fallback/export consumers are classified separately. +- Browser/export code: Gecko/XULRunner initialization currently enables Graphite rendering and `XWebBrowser`/`GeckofxHtmlToPdf` support preview, print, and PDF flows. Those paths must be audited, replaced, or moved outside the default Avalonia Lexical Edit boundary before default switch. +- Configuration: `DistFiles/Language Explorer/Configuration/Parts/*.fwlayout` and `*Parts.xml` become migration inputs to a managed typed view definition rather than the long-term runtime UI format. +- Dependencies: Avalonia/Avalonia.Headless packages may be updated in sync; owned FieldWorks controls should be preferred over hard-forking third-party controls unless a narrow upstream/local patch is justified. diff --git a/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md new file mode 100644 index 0000000000..1eb5607207 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md @@ -0,0 +1,121 @@ +# Avalonia Migration Region Manifest + +The manifest is the contract for what a migrated Lexical Edit region owns, what legacy services it may adapt, and what dependencies are forbidden from the new default path. It is not implemented yet; Phase 3 must introduce it behind a default-off switch and executable audits. + +## 1. Manifest Shape + +Each migrated region should declare: + +| Field | Meaning | +|---|---| +| `regionId` | Stable identifier such as `lexical-edit.entry.identity`. | +| `ownerProject` | Owning project/module, for this change `AdvancedEntry.Avalonia`. | +| `legacySurface` | Legacy host/slice/layout being replaced or wrapped. | +| `enabledByDefault` | `false` until all gates pass for that region. | +| `rollbackSurface` | Legacy view or command used when the migrated region is disabled or fails capability checks. | +| `allowedAdapters` | Narrow legacy services the region may call. | +| `forbiddenSymbols` | Symbols/packages prohibited from production default-path code. | +| `requiredCapabilities` | Rendering, IME, accessibility, validation, undo/redo, localization, and layout override capabilities required for enablement. | +| `testEvidence` | Test files, fixture IDs, audit commands, and last known result. | + +Example draft: + +```json +{ + "regionId": "lexical-edit.entry.identity", + "ownerProject": "AdvancedEntry.Avalonia", + "legacySurface": "LexEntry-detail-Normal identity fields in DataTree", + "enabledByDefault": false, + "rollbackSurface": "RecordEditView/DataTree", + "allowedAdapters": [ + "LcmCache main-thread read/write through approved edit session", + "metadata cache immutable snapshots", + "XCore command bridge in shell phase only", + "FieldWorks diagnostics/logging" + ], + "forbiddenSymbols": [ + "System.Windows.Forms.Control", + "DataTree", + "Slice", + "RootSiteControl", + "XmlView", + "BrowseViewer", + "IVwRootBox", + "IVwEnv", + "IRenderEngine", + "GraphiteEngineClass", + "UniscribeEngineClass", + "GeckoWebBrowser", + "XWebBrowser" + ], + "requiredCapabilities": [ + "undo-redo", + "validation", + "keyboard-focus", + "accessibility-metadata", + "localized-strings", + "layout-overrides", + "writing-system-fonts" + ] +} +``` + +## 2. Allowed Legacy Adapters + +Adapters must be explicit and testable. + +| Adapter | Rule | +|---|---| +| `LcmCache` / LCModel | Allowed only through main-thread edit sessions or immutable snapshots. Do not read mutable cache objects on background threads. | +| Metadata cache | Allowed for class/flid/type lookup; snapshot values before background compilation. | +| Undo/redo | Must route through LCModel action handler semantics; local Avalonia commands cannot bypass global undo history. | +| XCore mediator/property table | Shell-phase adapter only. First-slice preview code must stay decoupled. | +| Diagnostics | Use FieldWorks `System.Diagnostics`/trace-switch pipeline. | +| Localization | User-visible production strings must use resource patterns; test-only strings can remain in tests. | + +## 3. Forbidden Default-Path Dependencies + +The manifest audit must fail migrated production code that directly references: + +- WinForms controls or hosts: `System.Windows.Forms`, `DataTree`, `Slice`, `RootSiteControl`, `RecordEditView` internals. +- XMLViews/native Views rendering: `XmlView`, `BrowseViewer`, `IVwRootBox`, `IVwEnv`, `IVwGraphics`. +- Native render engines: `IRenderEngine`, `IRenderEngineFactory`, `GraphiteEngineClass`, `UniscribeEngineClass`, `FwGrEngine`. +- Browser/PDF preview engines: `GeckoWebBrowser`, `XWebBrowser`, `GeckofxHtmlToPdf`, `FieldWorksPdfMaker`. +- Global COM registration, registry hacks, or direct native-boundary calls with unsanitized input. + +Exceptions must be documented in the manifest with owner, reason, tests, and rollback behavior. + +## 4. Gates + +| Gate | Required Evidence | +|---|---| +| Schema gate | Manifest validates against a checked-in schema and has an owner/rollback/test evidence entry. | +| Symbol audit gate | Automated search over migrated production code finds no forbidden symbols except approved exceptions. | +| Layout gate | Typed presentation snapshot matches selected DataTree/XML layout baselines for the region. | +| Edit gate | Save, cancel, nested session rejection, undo/redo, and refresh interaction tests pass. | +| Validation gate | Required fields, deterministic order, localized message metadata, severity, async stale-result handling, and accessibility exposure pass. | +| Accessibility gate | Controls expose stable automation IDs/names/roles where Avalonia supports them; keyboard-only navigation has headless or UI automation evidence. | +| Rendering gate | Writing-system/font/Graphite capability matrix is classified and default path blocks unsupported cases with rollback. | +| Performance gate | Provisional budgets are measured against named fixtures and hardware before becoming enablement criteria. | + +## 5. Provisional Performance Budgets + +Budgets are placeholders until measured. They must not be used as pass/fail claims until each has fixture ID, machine profile, command, and artifact path. + +| Metric | Provisional Target | Notes | +|---|---|---| +| First region load | Within 20 percent of legacy baseline or explicitly accepted | Measure cold and warm separately. | +| Layout compile | Deterministic and cacheable; target under 250 ms for selected first-slice fixture | Use immutable config snapshots. | +| Save/cancel command latency | No user-visible freeze for first editable slice | Measure UI-thread work and background work separately. | +| Validation pass | Linear in materialized node count | Lazy/unmaterialized sequences must be skipped or explicitly loaded. | + +## 6. Phasing + +| Phase | Manifest Work | +|---|---| +| Phase 1 | Define manifest schema, allowed/forbidden dependency policy, and audit command design. | +| Phase 2 | Attach current coverage report and identify blocked gates. | +| Phase 3 | Introduce default-off region manifest and symbol audit in tests or agent scripts. | +| Phase 4-6 | Add region evidence as typed layout import, edit sessions, validation, commands, and focus mature. | +| Phase 7-8 | Run accessibility/performance/rendering evidence against candidate default regions. | +| Phase 9 | Enable a region by default only when all gates pass and rollback remains available. | \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md new file mode 100644 index 0000000000..d7f6969b16 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md @@ -0,0 +1,153 @@ +# Avalonia Seam Recommendations + +This note records the recommended seam direction for the Lexical Edit Avalonia migration. It is advisory; the companion seam docs define the concrete gates and tests. Current implementation is deliberately distinguished from proposed seams. + +Supporting docs: + +- `avalonia-edit-sessions.md` +- `avalonia-undo-redo.md` +- `avalonia-validation.md` +- `avalonia-command-focus.md` +- `avalonia-ui-scheduler.md` +- `avalonia-lifetime.md` + +## Edit Sessions + +**Current implementation:** `AdvancedEntryEditSession` is a concrete fenced LCModel undo-task session with `Save()` and `Cancel()`. + +**Recommendation:** Keep the direct LCModel fenced undo-task model for the first editable slice, then extract a FieldWorks-owned edit-session seam only with lifecycle, rollback, and global undo/redo tests. + +**Alternatives considered:** + +1. Direct LCModel fenced session. +Pros: closest to current code and LCModel action-handler semantics. +Cons: cancel/save semantics need strong tests before many fields are editable. + +2. Staged draft model. +Pros: clean pre-commit validation and cancel behavior. +Cons: larger mapping layer and conflict/stale-state work. + +3. Package-led draft editing using ReactiveUI or similar. +Pros: good local ergonomics. +Cons: does not solve LCModel transactions and adds framework commitment. + +**Revisit trigger:** adopt staged drafts only after a first-slice test proves direct sessions create unacceptable complexity or user-visible risk. + +**References:** Avalonia data validation, Avalonia commanding/hotkeys, MVVM Toolkit, ReactiveUI commands/validation. + +## Undo and Redo + +**Current implementation:** local save/cancel exists; there is no implemented `IUndoRedoCoordinator`. + +**Recommendation:** Keep control-local text undo as leaf behavior, but make global undo/redo authoritative through FieldWorks/LCModel transaction routing. + +**Alternatives considered:** + +1. Pure FieldWorks global transaction stack. +Pros: correct persisted-state semantics. +Cons: needs control integration work. + +2. Package-first object/view-model history. +Pros: easy prototype. +Cons: risks conflicting histories and wrong LCModel source of truth. + +3. Hybrid local text undo plus global LCModel undo. +Pros: best desktop editing UX while preserving domain history. +Cons: requires explicit focus/command routing rules. + +**Revisit trigger:** only for a specific owned control that needs richer document-local undo and still commits through LCModel. + +## Validation + +**Current implementation:** `ValidationService` performs deterministic required-field checks over Presentation IR and skips unmaterialized lazy items. + +**Recommendation:** Use a FieldWorks-owned validation model with Avalonia presentation adapters, preferably `INotifyDataErrorInfo` or `DataValidationErrors` where that maps cleanly to controls. + +**Alternatives considered:** + +1. Native Avalonia validation only. +Pros: simple and idiomatic. +Cons: insufficient for cross-object rules, localization metadata, and non-materialized nodes. + +2. FluentValidation/ReactiveUI behind the seam. +Pros: strong rule composition. +Cons: should remain implementation detail, not migration contract. + +3. Domain validation seam with Avalonia adapters. +Pros: reusable across tests, preview host, and shell integration. +Cons: requires structured issue paths and localization contract. + +**Revisit trigger:** collapse to native-only validation only for isolated dialogs or surfaces with no LCModel/cross-object semantics. + +## Command and Focus + +**Current implementation:** the spike has local Avalonia key bindings and view-model commands. There is no XCore command bridge yet. + +**Recommendation:** Use local Avalonia commands for first-slice/preview behavior; introduce a FieldWorks/XCore bridge only during shell integration. + +**Alternatives considered:** + +1. Avalonia built-ins only. +Pros: fast and idiomatic. +Cons: insufficient for shell menus, command state, and active target resolution. + +2. MVVM package commands. +Pros: nice local command ergonomics. +Cons: still needs shell routing. + +3. Custom bridge to XCore/property state. +Pros: correct shell integration. +Cons: easy to over-preserve legacy quirks if introduced too early. + +**Revisit trigger:** narrow or defer the bridge if shell-global command needs are smaller than expected. + +## UI Scheduler + +**Current implementation:** dispatcher calls are used directly in the Avalonia module; no shared scheduler seam exists. + +**Recommendation:** Introduce a thin `IUiScheduler` only where non-view code needs testable UI-thread marshalling, cancellation, or exception propagation. Keep direct dispatcher use at concrete UI edges. + +**Alternatives considered:** + +1. Direct dispatcher everywhere. +Pros: simplest code. +Cons: hard to fake and leaks Avalonia into service layers. + +2. Thin wrapper. +Pros: easy to test and small. +Cons: can become pointless if it only renames APIs. + +3. Reactive scheduler abstraction. +Pros: powerful for reactive screens. +Cons: unnecessary as global default. + +**Revisit trigger:** collapse low-value wrappers that provide no test or architecture value. + +## Lifetime + +**Current implementation:** `MainWindowViewModel` owns and disposes the loaded lifetime on save/cancel; no `ILexicalLifetimeManager` exists. + +**Recommendation:** Keep ownership explicit in the view model for the first slice; extract a lifetime manager only after late-loader, idempotent-disposal, event-unsubscribe, and shell-unload tests exist. + +**Alternatives considered:** + +1. Direct Avalonia lifetime everywhere. +Pros: quickest for preview-host code. +Cons: spreads shutdown/disposal policy. + +2. Thin lifetime manager. +Pros: testable owner for sessions, loaders, callbacks, and shell registrations. +Cons: premature if first slice stays small. + +3. Heavy region/document lifetime framework. +Pros: strongest explicit ownership tree. +Cons: overdesign until repeated cross-screen lifetime failures prove need. + +**Revisit trigger:** introduce heavier framework only when repeated region/window ownership bugs appear. + +## Research References + +- Avalonia official docs: data validation, binding validation, commanding, keyboard/hotkeys, focus, dispatcher threading, headless testing, app lifetimes, windows/dialogs, accessibility automation properties. +- Microsoft docs: MVVM Toolkit commands and `ObservableValidator`. +- ReactiveUI docs: commands, validation, and scheduler testing. +- HarfBuzz official docs: Graphite2 shaping requires HarfBuzz built with Graphite2 enabled and is not enabled by default. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/interop/native-boundary/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/interop/native-boundary/spec.md new file mode 100644 index 0000000000..e0d801b183 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/interop/native-boundary/spec.md @@ -0,0 +1,41 @@ +## ADDED Requirements + +### Requirement: Migrated Avalonia regions eliminate viewing/render interop boundary + +Completed Avalonia regions SHALL NOT use the managed-to-native Views render interop boundary for display, layout, measurement, selection, hit testing, scrolling, or editor realization. + +#### Scenario: Native render interop is absent from completed region +- **WHEN** a Lexical Edit region is marked complete for Avalonia migration +- **THEN** dependency analysis or instrumentation SHALL show no runtime use of Views COM render interfaces, `RootSite`/`SimpleRootSite`, `IVwEnv`, `ManagedVwWindow`, RootBox rendering, or equivalent native render adapters from that region + +#### Scenario: Native interop remains allowed outside completed region +- **WHEN** another FieldWorks region still depends on native Views rendering +- **THEN** that dependency SHALL remain outside the completed Avalonia region boundary +- **AND** it SHALL be tracked as a separate repo-wide native Views retirement blocker + +### Requirement: Graphite native interop is decommissioned for Avalonia default + +The Avalonia default Lexical Edit path SHALL NOT instantiate or call native Graphite render interop, including `graphite2`, `GraphiteEngine`, or Graphite-enabled `IRenderEngine` selection. + +#### Scenario: Graphite COM renderer is absent +- **WHEN** Avalonia is proposed as the default Lexical Edit screen +- **THEN** dependency analysis SHALL show no default-path creation of `GraphiteEngineClass`, no Graphite `IRenderEngine` selection, and no default-path dependency on `Lib/src/graphite2` + +### Requirement: Non-viewing native dependencies are classified by boundary + +Native dependencies that are not windowing and not Graphite SHALL be classified before default switch as migrated-region blockers, service dependencies outside the viewing/render/editor path, or repo-wide legacy dependencies. + +#### Scenario: Custom linguistics native services may remain +- **WHEN** native or external code provides custom linguistics capability such as XAmple, spelling, parser/conversion tools, ICU, or Encoding Converters +- **THEN** it SHALL be allowed to remain behind explicit service contracts +- **AND** it SHALL be kept outside Avalonia display, layout, measurement, hit testing, selection, scrolling, and editor-realization responsibilities + +#### Scenario: Spell-check interop is replaced or isolated +- **WHEN** a migrated Avalonia region offers spelling behavior +- **THEN** it SHALL use a managed service boundary or another Avalonia-compatible service +- **AND** it SHALL NOT depend on RootBox `SetSpellingRepository(IGetSpellChecker)` integration + +#### Scenario: Parser and conversion tools remain outside viewing/render completion gate +- **WHEN** Lexical Edit workflows invoke native/external parser, XAmple, encoding-converter, ICU, or reg-free COM infrastructure +- **THEN** those dependencies SHALL be documented as service/tooling dependencies outside Avalonia rendering +- **AND** they SHALL NOT be used to justify keeping native Views or Graphite in the migrated UI region diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/testing/test-strategy/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/testing/test-strategy/spec.md new file mode 100644 index 0000000000..7587b698fc --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/testing/test-strategy/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: UI migration tests are layered by responsibility + +FieldWorks UI migration tests SHALL separate pure logic, integration, semantic render verification, WinForms UIA2 workflow smoke tests, and Avalonia.Headless interaction tests. + +#### Scenario: Business logic is not asserted only through UI automation +- **WHEN** behavior can be tested through services, view-definition compilation, LCModel integration, or render semantics +- **THEN** it SHALL have a non-UIA test path rather than relying only on WinForms or Avalonia UI automation + +#### Scenario: UI automation remains focused +- **WHEN** a test uses UIA2 or Avalonia.Headless +- **THEN** it SHALL verify interaction wiring, accessibility/reachability, input handling, or visual realization that cannot be covered by lower-level tests + +### Requirement: Test plans cover coverage gaps before refactor + +Any refactor or Avalonia replacement touching Lexical Edit SHALL include either existing coverage evidence or planned tests for the affected behavior before implementation proceeds. + +#### Scenario: Coverage gap is explicit +- **WHEN** a migration task identifies missing test coverage for a legacy behavior +- **THEN** the task SHALL add coverage first or record why coverage must be deferred and what parity artifact will replace it diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/views-rendering/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/views-rendering/spec.md new file mode 100644 index 0000000000..cfb6a91907 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/views-rendering/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Render verification supports semantic migration comparison + +The render verification framework SHALL support semantic capture for migration comparisons between legacy Views/DataTree output, typed view-definition output, and Avalonia output. + +#### Scenario: Semantic capture runs beside pixel capture +- **WHEN** a render baseline scenario captures a Lexical Edit view +- **THEN** it SHALL be able to emit both visual artifacts and semantic artifacts for fields, editors, visibility, bindings, focus order, and accessibility identity + +#### Scenario: Avalonia comparison is supported +- **WHEN** an Avalonia implementation exists for the same scenario +- **THEN** the render verification framework SHALL compare legacy, typed IR, and Avalonia semantic artifacts before treating pixel differences as behavior regressions + +### Requirement: Render timing separates compilation and control creation + +Render and migration timing artifacts SHALL separate XML/import work, typed compilation, cache hits, control realization, text rendering, and capture/comparison time. + +#### Scenario: Timing identifies migration bottleneck +- **WHEN** a Lexical Edit migration benchmark runs +- **THEN** the timing output SHALL identify whether time is spent in XML import, typed compilation, cache miss, legacy control creation, Avalonia control realization, text shaping, or render capture + +### Requirement: Migrated Avalonia regions do not use native render pipeline + +The rendering architecture SHALL treat native Views/C++ viewing, layout, measurement, hit testing, selection, and editor realization as legacy-only for migrated regions. Completed Avalonia regions SHALL render and edit through Avalonia-managed controls and text services without runtime calls into native Views render code. + +#### Scenario: Native render use is visible during comparison only +- **WHEN** render verification compares a legacy region and a migrated Avalonia region +- **THEN** native Views/C++ viewing/rendering SHALL be permitted only for the legacy baseline capture +- **AND** the Avalonia capture SHALL use the managed/Avalonia rendering path exclusively + +#### Scenario: Runtime native render call fails completion audit +- **WHEN** instrumentation or dependency analysis detects a migrated Avalonia region calling native Views/C++ viewing/rendering/editor infrastructure at runtime +- **THEN** the region SHALL fail the migration completion audit + +#### Scenario: Linguistics service call does not fail render audit +- **WHEN** a migrated Avalonia region calls a native or external linguistics service through a managed contract +- **AND** the service does not own display, layout, hit testing, selection, or editor realization +- **THEN** the call SHALL be classified outside the render pipeline audit + +### Requirement: Migrated Avalonia rendering is Graphite-free + +Avalonia rendering SHALL use managed/Avalonia text services with OpenType/HarfBuzz font features and SHALL NOT use Graphite render engines, Graphite font tables, or Gecko Graphite rendering in the default Lexical Edit path. + +#### Scenario: Graphite engine use fails default-readiness audit +- **WHEN** validation detects `graphite2`, `GraphiteEngine`, Graphite-enabled `RenderEngineFactory` selection, or Gecko Graphite rendering in the default Avalonia path +- **THEN** the default-readiness audit SHALL fail diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/winforms-patterns/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/winforms-patterns/spec.md new file mode 100644 index 0000000000..e5ebdd2bd4 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/architecture/ui-framework/winforms-patterns/spec.md @@ -0,0 +1,21 @@ +## ADDED Requirements + +### Requirement: DetailControls refactors introduce explicit service seams + +WinForms DetailControls refactors SHALL introduce explicit interfaces for DataTree services, refresh coordination, editor selection, launcher behavior, LCModel access, diagnostics, and host integration before replacing equivalent UI with Avalonia. + +#### Scenario: Slice creation is reachable through an editor registry +- **WHEN** a slice/editor is created from legacy XML metadata +- **THEN** the selection of editor kind SHALL pass through a registry or service boundary that can later resolve either legacy WinForms slices or Avalonia editors + +#### Scenario: Refresh behavior is testable without full UI replacement +- **WHEN** DataTree refresh behavior is refactored +- **THEN** refresh state transitions SHALL be covered by tests independent of full Lexical Edit UI automation + +### Requirement: WinForms controls expose automation metadata for migration baselines + +Legacy WinForms controls involved in migration baselines SHALL expose stable accessible names, roles, or automation identifiers where practical. + +#### Scenario: Baseline target has stable accessible identity +- **WHEN** a UIA2 baseline targets a DataTree, slice, launcher, table header, filter, popup, or chooser control +- **THEN** the target SHALL have a stable accessible identity or a documented fallback locator strategy diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-command-focus/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-command-focus/spec.md new file mode 100644 index 0000000000..aa3fcb36b1 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-command-focus/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Global command and focus behavior uses a FieldWorks-owned bridge + +Global command routing, active-target resolution, popup focus return, and shell-level command state SHALL use a FieldWorks-owned command and focus bridge rather than relying solely on direct Avalonia control bindings. + +#### Scenario: Global command resolves active target +- **WHEN** a migrated global command such as save, delete, or find is invoked from a menu, toolbar, shortcut, or context menu +- **THEN** the command SHALL resolve the active target through the FieldWorks-owned command and focus bridge + +### Requirement: Local Avalonia commands remain allowed inside screens + +Migrated screens SHALL be allowed to use direct Avalonia `ICommand`, `KeyBinding`, `HotKey`, CommunityToolkit commands, or similar local command helpers for screen-local behavior when they do not replace the global command and focus bridge. + +#### Scenario: Local editor command stays local +- **WHEN** a screen-local editor or popup handles a command that does not require shell-wide target resolution +- **THEN** the screen MAY bind that behavior directly through local Avalonia or MVVM command helpers + +### Requirement: Command descriptors separate execution from display state + +The shell command model SHALL keep stable command identity, visibility, checked state, enabled state, gestures, and diagnostics separate from the execution mechanism. + +#### Scenario: Menu and toolbar share command descriptor +- **WHEN** a command appears in more than one shell surface +- **THEN** those surfaces SHALL be driven from a shared command descriptor rather than duplicating per-surface state logic diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-edit-sessions/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-edit-sessions/spec.md new file mode 100644 index 0000000000..f8c68d5040 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-edit-sessions/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Editable Avalonia regions use a hybrid edit-session boundary + +Editable Avalonia regions SHALL use a hybrid edit-session boundary where UI-facing draft or staged state remains detached from live LCModel mutation until an explicit FieldWorks-owned edit session commits the change. + +#### Scenario: Draft state commits through FieldWorks session +- **WHEN** a migrated editor saves changes +- **THEN** the editor SHALL apply staged changes through a FieldWorks-owned edit-session or edit-transaction service +- **AND** the commit path SHALL own LCModel transaction semantics, rollback behavior, and commit fencing + +### Requirement: The authoritative edit-session contract remains FieldWorks-owned + +The authoritative edit-session contract for migrated lexical editing SHALL remain FieldWorks-owned and LCModel-aware rather than delegated to a package-specific view-model framework. + +#### Scenario: Package helpers do not replace commit boundary +- **WHEN** CommunityToolkit, ReactiveUI, or similar UI helpers are used for draft state, commands, or validation +- **THEN** those helpers SHALL remain outside the authoritative LCModel commit and rollback boundary + +### Requirement: Simple non-persistent dialogs may use lighter draft state + +Simple non-persistent dialogs or preview-only surfaces SHALL be allowed to use lighter screen-local draft state when they do not commit directly to LCModel and do not participate in migrated lexical edit-session guarantees. + +#### Scenario: Preview host avoids live edit session +- **WHEN** a preview host or sample-data surface renders an editor without a live project cache +- **THEN** it MAY use staged or sample draft state without opening a live LCModel edit session diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-lifetime/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-lifetime/spec.md new file mode 100644 index 0000000000..8b0d650daa --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-lifetime/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Non-view code uses a thin lifetime and dialog seam + +Application lifetime, dialog ownership, shutdown requests, and non-view window coordination SHALL use a thin FieldWorks-owned lifetime seam instead of direct Avalonia lifetime calls from non-view layers. + +#### Scenario: Presenter requests dialog through seam +- **WHEN** a presenter or non-view service needs to show a dialog, request shutdown, or coordinate owner windows +- **THEN** it SHALL do so through the lifetime and dialog seam rather than directly referencing Avalonia window lifetime APIs + +### Requirement: Direct Avalonia lifetime remains allowed at the UI edge + +Direct Avalonia lifetime APIs SHALL remain allowed in `Program`, `App`, preview-host startup, headless-test setup, and concrete window or dialog classes. + +#### Scenario: App startup uses classic desktop lifetime directly +- **WHEN** the concrete Avalonia application starts or a preview host boots a top-level window +- **THEN** the concrete startup path MAY use Avalonia lifetime APIs directly at that edge + +### Requirement: Full region or document lifetime frameworks are deferred + +The migration SHALL NOT require a heavy region, document, or workspace lifetime framework up front; such a framework SHALL be introduced only if repeated cross-screen lifetime problems prove a thin seam insufficient. + +#### Scenario: Thin lifetime seam remains default until repeated need is proven +- **WHEN** initial migrated screens and shell slices can be coordinated through the thin lifetime seam +- **THEN** the migration SHALL defer a heavier region or document lifetime framework rather than introducing it by default diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-ui-scheduler/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-ui-scheduler/spec.md new file mode 100644 index 0000000000..ed30b76ef3 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-ui-scheduler/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Non-view layers use a thin UI scheduling seam + +Presenters, view models outside view code, edit sessions, and migration services that need UI-thread marshalling SHALL use a thin FieldWorks-owned UI scheduling seam instead of reaching directly to Avalonia dispatcher APIs. + +#### Scenario: Background load marshals through seam +- **WHEN** a migrated workflow completes background work and needs to publish results to the UI thread +- **THEN** it SHALL marshal through the UI scheduling seam rather than directly calling Avalonia dispatcher APIs from non-view layers + +### Requirement: Direct Avalonia dispatcher use stays at the UI edge + +Direct use of `Dispatcher.UIThread` or equivalent Avalonia dispatcher APIs SHALL remain allowed in `Program`, `App`, `Window`, `UserControl`, preview-host startup, and headless-test adapter code. + +#### Scenario: Window code uses direct dispatcher +- **WHEN** view-specific code-behind or startup code needs direct dispatcher access +- **THEN** it MAY use Avalonia dispatcher APIs without routing through the scheduling seam + +### Requirement: Reactive schedulers are optional local helpers + +Reactive or framework-specific schedulers MAY be used as local implementation details when a screen explicitly adopts them, but SHALL NOT become the global migration scheduling contract by default. + +#### Scenario: Reactive screen does not redefine app scheduler contract +- **WHEN** a migrated screen uses ReactiveUI or similar reactive helpers internally +- **THEN** that choice SHALL remain local to the screen and SHALL NOT replace the shared UI scheduling seam for the migration diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-undo-redo/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-undo-redo/spec.md new file mode 100644 index 0000000000..ea3a561365 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-undo-redo/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Global undo and redo remain domain-authoritative + +Global undo and redo for migrated lexical editing SHALL remain authoritative at the FieldWorks or LCModel transaction layer. + +#### Scenario: Domain edit participates in global undo +- **WHEN** a migrated lexical edit changes persisted project state +- **THEN** the resulting undo and redo behavior SHALL be recorded through the FieldWorks or LCModel undo infrastructure rather than a package-local view-model history + +### Requirement: Control-local undo is allowed only as leaf history + +Avalonia control-local undo and redo MAY be used as leaf editing history while focus remains inside a control, but SHALL NOT replace the global domain-authoritative undo model for persisted lexical edits. + +#### Scenario: TextBox undo stays local until commit boundary +- **WHEN** a user is actively editing text inside a focused Avalonia control +- **THEN** the control MAY expose local undo and redo behavior for in-control text changes +- **AND** persisted lexical undo history SHALL still be routed through the domain-authoritative undo boundary + +### Requirement: Grouped edits and chooser workflows use domain transactions + +Grouped edits, chooser dialogs, nested edit scopes, and other workflows that affect project state SHALL use FieldWorks-owned undo grouping and transaction boundaries. + +#### Scenario: Chooser confirm creates grouped undo item +- **WHEN** a chooser or popup confirms a change that mutates project state +- **THEN** the change SHALL participate in a grouped FieldWorks undo transaction with deterministic cancel and rollback behavior diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-validation/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-validation/spec.md new file mode 100644 index 0000000000..f3915126ae --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/avalonia-validation/spec.md @@ -0,0 +1,25 @@ +## ADDED Requirements + +### Requirement: Durable validation rules live behind a FieldWorks-owned validation seam + +Durable lexical validation rules SHALL live behind a FieldWorks-owned validation seam that can evaluate staged edits independently of Avalonia control materialization. + +#### Scenario: Validation runs without forcing editor creation +- **WHEN** a migrated lexical region validates staged data +- **THEN** validation SHALL be able to evaluate required rules, cross-field rules, and commit gates without forcing live editor or control materialization + +### Requirement: Avalonia presents validation through native binding surfaces + +Avalonia editors SHALL present validation state through native Avalonia binding and validation surfaces such as `INotifyDataErrorInfo`, `DataValidationErrors`, or equivalent UI adapters. + +#### Scenario: Field issue appears in Avalonia editor +- **WHEN** the validation seam reports a field-scoped issue for visible staged data +- **THEN** the corresponding Avalonia editor SHALL expose the error state through native Avalonia validation presentation and accessibility metadata + +### Requirement: Package validators remain subordinate to the validation seam + +DataAnnotations, CommunityToolkit validation helpers, FluentValidation, or similar libraries MAY be used behind the validation seam or for simple dialogs, but SHALL NOT replace the FieldWorks-owned validation contract for migrated lexical editing. + +#### Scenario: FluentValidation stays behind seam +- **WHEN** a validator library is used to implement cross-field, collection, async, or localized rules +- **THEN** the library SHALL feed structured validation results into the FieldWorks-owned validation seam rather than becoming the public migration contract diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md new file mode 100644 index 0000000000..a081ce61de --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md @@ -0,0 +1,135 @@ +## ADDED Requirements + +### Requirement: Migration is phased by risk and control complexity + +The Lexical Edit migration SHALL proceed in phases: baseline test coverage, refactoring seams, simple Avalonia controls and popup hovers, table/browse views, slices, and then full Lexical Edit views. + +#### Scenario: Refactor gates precede Avalonia replacement +- **WHEN** a migration task replaces a Lexical Edit surface with Avalonia +- **THEN** the affected legacy behavior SHALL already have unit/integration coverage or an explicit baseline plan in `lexical-edit-parity-automation` +- **AND** required service seams SHALL be identified before UI replacement begins + +#### Scenario: Control complexity determines rollout order +- **WHEN** scheduling Avalonia replacement work +- **THEN** simple editors and popup hovers SHALL be attempted before table views +- **AND** table views SHALL be attempted before slice and full Lexical Edit replacement + +### Requirement: User interaction and density are preserved + +Avalonia replacements SHALL preserve the legacy user interaction model, information density, keyboard/focus behavior, popup semantics, and layout hierarchy within documented near-pixel tolerances. + +#### Scenario: Dense field editing parity +- **WHEN** a migrated lexical entry field group is shown in Avalonia +- **THEN** it SHALL expose equivalent labels, editor affordances, focus order, hover/popup entry points, and visible data density as the legacy DataTree baseline + +#### Scenario: Pixel differences are explained +- **WHEN** Avalonia output differs visually from the WinForms baseline +- **THEN** the comparison artifact SHALL identify whether the difference is accepted near-pixel variance, font/rendering variance, missing data, or a behavior regression + +### Requirement: Avalonia text uses writing-system font settings and OpenType shaping + +Avalonia lexical editors SHALL use FieldWorks writing-system font settings, Avalonia/Skia text rendering, and HarfBuzz/OpenType feature support. Graphite SHALL NOT be supported in Avalonia. + +#### Scenario: Writing-system text editor binds font metadata +- **WHEN** a multi-writing-system field is rendered in Avalonia +- **THEN** each writing-system alternative SHALL use the configured font family, size, flow direction, culture/script metadata, and OpenType feature settings available for that writing system + +#### Scenario: Graphite-only behavior blocks default switch +- **WHEN** a legacy writing system or font depends on Graphite-only shaping or feature IDs +- **THEN** the migration SHALL block Avalonia becoming the default screen until the writing system is migrated to OpenType/HarfBuzz-compatible font settings, replaced with an acceptable font option, or documented as an unsupported legacy dependency outside the default Avalonia path + +### Requirement: Graphite is decommissioned before Avalonia becomes default + +Graphite decommissioning SHALL start when the Lexical Edit Avalonia migration starts. Avalonia SHALL NOT become the default Lexical Edit screen until Graphite runtime dependencies, font options, Gecko rendering assumptions, and default-path tests/docs are removed or converted to OpenType/HarfBuzz-only behavior. + +#### Scenario: Stealth migration still starts Graphite retirement +- **WHEN** Avalonia Lexical Edit work begins behind flags, preview hosts, or non-default entry points +- **THEN** the work SHALL include Graphite inventory and retirement tasks from the start + +#### Scenario: Default switch requires Graphite-free evidence +- **WHEN** Avalonia is proposed as the default Lexical Edit screen +- **THEN** validation SHALL prove that the default path does not call Graphite native code, create Graphite render engines, enable Gecko Graphite rendering, depend on Graphite feature strings, or require Graphite-only sample fonts + +### Requirement: FieldWorks-owned controls cover domain-specific editors + +The migration SHALL prefer FieldWorks-owned Avalonia editor controls over permanent dependence on generic property-grid behavior for multi-writing-system text, rich text, choosers, feature structures, references, nested sequences, and TreeView-heavy views. + +#### Scenario: PropertyGrid remains a bootstrap path +- **WHEN** the current Advanced Entry PropertyGrid prototype is used for migration learning +- **THEN** it SHALL NOT define the final Lexical Edit UI contract +- **AND** final editors SHALL bind to typed view-definition/IR nodes through owned editor interfaces + +#### Scenario: TreeView supports multiple translations per sense or term +- **WHEN** a migrated tree view displays senses, terms, examples, glosses, definitions, or translations +- **THEN** each tree node SHALL be able to render multiple writing-system alternatives and compact inline metadata without requiring a separate modal dialog for normal inspection + +### Requirement: Avalonia regions declare completion manifests + +Each migrated Avalonia region SHALL define a completion manifest before implementation is marked complete. The manifest SHALL list entry points, typed view-definition sources, allowed legacy adapters, forbidden native viewing/rendering and Graphite call paths, retained custom linguistics services, parity fixtures, customer override fixtures, accessibility IDs, performance budgets, and rollback/default-switch gates. + +#### Scenario: Region manifest blocks ambiguous completion +- **WHEN** a region is proposed as migrated +- **THEN** its manifest SHALL identify the tests, instrumentation, fixtures, and default-switch evidence required for that region +- **AND** missing manifest entries SHALL block completion + +### Requirement: Avalonia editors use explicit edit sessions + +Avalonia editors SHALL use explicit edit-session or edit-transaction services for staged values, validation, cancellation, LCModel commit behavior, dirty state, undo/redo grouping, and command enablement. + +#### Scenario: Edit is committed through transaction seam +- **WHEN** a migrated editor commits a value +- **THEN** the edit SHALL pass through the edit-session boundary +- **AND** validation, LCModel transaction behavior, undo/redo grouping, and refresh notifications SHALL be observable by tests + +#### Scenario: Edit is canceled without side effects +- **WHEN** a migrated editor cancels a pending edit +- **THEN** the editor SHALL restore display state without committing LCModel changes or creating undo items + +### Requirement: Avalonia platform seams are explicit + +Avalonia regions SHALL use explicit services for UI dispatch, focus navigation, command routing, region lifetime/disposal, styling resources, design/preview data, and accessibility metadata rather than reaching through WinForms, DataTree, or xCore UI objects. + +#### Scenario: Focus and command behavior are tested through services +- **WHEN** a migrated editor handles shortcuts, context menus, popup focus return, or command enablement +- **THEN** that behavior SHALL be routed through explicit command/focus services +- **AND** Avalonia.Headless or semantic parity tests SHALL cover the behavior + +### Requirement: Package updates and control hacks are gated by parity evidence + +Avalonia package updates, third-party control additions, upstream patches, or local control hacks SHALL be allowed only when tied to a specific parity, density, text, table, or automation requirement. + +#### Scenario: Package change has migration justification +- **WHEN** an Avalonia package version or control dependency changes +- **THEN** the change SHALL document the blocked requirement, package/control rationale, and validation evidence from Avalonia.Headless or render parity tests + +### Requirement: Legacy XML and native Views are not new dependencies + +New Avalonia Lexical Edit functionality SHALL NOT require WinForms slices, XMLViews rendering, or native Views runtime to operate, except through migration importers and baseline comparison harnesses. + +#### Scenario: New Avalonia editor runs from typed contract +- **WHEN** a migrated Avalonia editor is launched in the Preview Host or headless tests +- **THEN** it SHALL receive a typed view-definition/IR model and injected services +- **AND** it SHALL NOT instantiate `DataTree`, `Slice`, `RootSite`, or native Views UI components + +### Requirement: C++ viewing/rendering dependencies are decommissioned by migrated region + +A Lexical Edit region SHALL NOT be considered fully migrated to Avalonia until that region has no runtime dependency on native code that owns display, layout, measurement, hit testing, selection, scrolling, editor realization, native Views box/layout/rendering, `RootSite`, `IVwEnv`, `ManagedVwWindow`, or equivalent native render adapters. + +#### Scenario: Region completion requires native viewing/render seam audit +- **WHEN** a Lexical Edit region is proposed as fully migrated to Avalonia +- **THEN** a dependency audit SHALL show that the region renders, measures, hit-tests, scrolls, selects, and edits without calling native Views/C++ viewing/rendering/editor infrastructure +- **AND** any remaining native render usage SHALL be limited to non-migrated regions or offline baseline comparison tools + +#### Scenario: Native viewing/render bridge blocks completion +- **WHEN** an Avalonia region still relies on native Views/C++ viewing/rendering infrastructure for display, layout, text measurement, selection, hit testing, scrolling, or editor realization +- **THEN** that region SHALL remain in migration status and SHALL NOT be marked complete + +#### Scenario: Custom linguistics services remain allowed +- **WHEN** a migrated Avalonia region invokes native or external linguistics services such as XAmple, spelling, parser/conversion tools, ICU, or Encoding Converters +- **THEN** those services SHALL be accessed through explicit service contracts outside the Avalonia render/editor path +- **AND** they SHALL NOT own UI display, layout, measurement, hit testing, selection, or editor realization for the migrated region + +#### Scenario: Shared native code deletion waits for all consumers +- **WHEN** a migrated Lexical Edit region no longer uses native Views/C++ viewing/rendering infrastructure but other FieldWorks regions still do +- **THEN** the migrated region SHALL be complete for its scope +- **AND** global native Views deletion SHALL remain a separate tracked dependency until remaining consumers are migrated or intentionally retained diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md new file mode 100644 index 0000000000..9d833b46df --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Graphite decommissioning starts with the migration + +The Lexical Edit Avalonia migration SHALL begin Graphite decommissioning immediately, even when Avalonia work is hidden behind preview hosts, feature flags, or non-default entry points. Avalonia SHALL never support Graphite. + +#### Scenario: Migration start creates Graphite retirement work +- **WHEN** implementation begins for Lexical Edit Avalonia migration +- **THEN** the task plan SHALL include inventory, migration, and validation tasks for retiring Graphite from the default path + +#### Scenario: Default screen is blocked by Graphite dependency +- **WHEN** Avalonia is proposed as the default Lexical Edit screen +- **THEN** Graphite dependencies SHALL be fully retired from the default path or explicitly classified as unsupported legacy dependencies outside that path + +### Requirement: Graphite code, settings, and assets are inventoried + +The migration SHALL inventory Graphite across native code, managed render selection, writing-system settings, UI, tests, docs, assets, browser rendering, print, PDF, and build/package files. + +#### Scenario: Inventory covers known Graphite surfaces +- **WHEN** Graphite decommissioning inventory runs +- **THEN** it SHALL cover at least `Lib/src/graphite2`, `Src/views/lib/GraphiteEngine.*`, `RenderEngineFactory`, `GraphiteFontFeatures`, `FontFeaturesButton`, `DefaultFontsControl`, `FwWritingSystemSetupModel`, `IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite`, Graphite-specific tests, `DistFiles/Graphite`, `Build/Windows.targets`, Gecko Graphite preferences, `XWebBrowser` preview consumers, and `GeckofxHtmlToPdf`/`FieldWorksPdfMaker` assumptions + +### Requirement: Font options migrate to OpenType and HarfBuzz + +Graphite font feature strings SHALL NOT be preserved as Avalonia runtime behavior. Avalonia font options SHALL use OpenType/HarfBuzz-compatible feature syntax and explicit font replacement or compatibility diagnostics for Graphite-only fonts. + +#### Scenario: Graphite feature string has no OpenType mapping +- **WHEN** a stored Graphite feature string or Graphite-only font cannot be mapped to OpenType/HarfBuzz behavior +- **THEN** the migration SHALL report an actionable compatibility diagnostic and SHALL NOT silently render it as equivalent in Avalonia + +#### Scenario: OpenType font features are applied per writing-system run +- **WHEN** a migrated Avalonia editor renders a writing-system run +- **THEN** it SHALL apply the writing system's OpenType/HarfBuzz-compatible feature settings, font family, fallback mapping, culture/script metadata, and flow direction + +### Requirement: Gecko and PDF rendering stop relying on Graphite + +Gecko/XULRunner and Gecko-backed PDF/print flows SHALL NOT be part of the default Avalonia Lexical Edit rendering path if they rely on Graphite rendering. + +#### Scenario: Gecko Graphite preference blocks default path +- **WHEN** default-path validation observes Gecko startup with `gfx.font_rendering.graphite.enabled` or an equivalent Graphite rendering dependency +- **THEN** Avalonia SHALL NOT become the default Lexical Edit screen until that path is replaced, disabled, or moved outside the default boundary + +#### Scenario: Browser/PDF replacement is validated +- **WHEN** XHTML preview, print, or PDF behavior is retained for migrated workflows +- **THEN** the replacement SHALL be validated with OpenType/HarfBuzz-compatible font behavior and SHALL NOT depend on `XWebBrowser` Graphite rendering or `GeckofxHtmlToPdf` Graphite shaping assumptions + +### Requirement: Remaining native dependencies are classified + +Native dependencies outside Graphite and window hosting SHALL be classified by migration impact before Avalonia becomes default. The classification SHALL distinguish native code that owns what the user views or edits from custom linguistics services that may remain behind managed contracts. + +#### Scenario: Native Views remains a render blocker +- **WHEN** a dependency uses native Views layout, selection, hit testing, editing, or interlinear rendering +- **THEN** it SHALL be treated as a migrated-region blocker, not as advanced windowing + +#### Scenario: Custom linguistics tools are isolated as services +- **WHEN** a dependency uses parser tools, XAmple, Encoding Converters, ICU, Expat/ParserObject, spelling interop, or reg-free COM tooling outside rendering +- **THEN** it SHALL be documented as a service/tooling dependency that supports FieldWorks language-documentation capability +- **AND** it SHALL be kept outside the Avalonia render/editor completion gate + +#### Scenario: Native viewing code is not imported as a service +- **WHEN** native code owns display, layout, measurement, hit testing, selection, scrolling, or editor realization +- **THEN** it SHALL be treated as viewing/rendering infrastructure +- **AND** it SHALL NOT be brought into a completed Avalonia region as a retained service dependency diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md new file mode 100644 index 0000000000..e62247fdaa --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: Legacy WinForms behavior has layered baselines + +Before refactoring or replacing a Lexical Edit surface, the system SHALL have unit, integration, render, semantic, or UIA2 baseline coverage appropriate to the surface risk. + +#### Scenario: Baseline plan exists before refactor +- **WHEN** a refactor touches DataTree, SliceFactory, Slice, launchers, XMLViews table views, popup choosers, or RecordEditView hosting +- **THEN** the change SHALL identify existing tests or add a baseline task covering the affected behavior + +### Requirement: UIA2 automation covers legacy workflow reachability + +Legacy WinForms automation SHALL use UIA2-style workflow tests only for stable, user-observable reachability such as focus, launcher buttons, chooser dialogs, context menus, table headers, filters, and popup windows. + +#### Scenario: UIA2 smoke test drives chooser launch +- **WHEN** a legacy reference or possibility field exposes a chooser launcher +- **THEN** the UIA2 baseline SHALL be able to focus the field, invoke the launcher, observe the chooser window, and cancel or accept it deterministically + +#### Scenario: Owner-drawn content uses fallback assertions +- **WHEN** UIA2 cannot inspect owner-drawn or Views-backed content deeply +- **THEN** the baseline SHALL pair workflow automation with render snapshots, semantic snapshots, or model assertions + +### Requirement: Avalonia.Headless covers new control interaction + +Avalonia controls SHALL have headless tests for behavior that cannot be proven by pure unit tests, including input, expand/collapse, hover/flyout activation, context menus, selection, validation state, and virtualized sequence behavior. + +#### Scenario: Headless text input updates staged state +- **WHEN** an Avalonia headless test focuses a migrated text editor and sends text input +- **THEN** the editor SHALL update the bound staged state or LCModel-backed edit session according to the active migration phase + +#### Scenario: Headless tree expansion realizes expected nodes +- **WHEN** a headless test expands a migrated tree node for senses, terms, or translations +- **THEN** the expected child nodes SHALL be realized without creating unrelated off-screen editor controls + +#### Scenario: Headless test covers editor platform seams +- **WHEN** an Avalonia editor is tested headlessly +- **THEN** the test SHALL cover command shortcuts, popup focus return, validation state, edit commit/cancel, keyboard/IME behavior where practical, accessibility metadata, and disposal/subscription cleanup + +### Requirement: Render framework captures semantic parity + +Render verification SHALL capture semantic snapshots in addition to pixel/timing artifacts for legacy WinForms, typed IR, and Avalonia outputs. + +Semantic snapshots SHALL normalize volatile values and key comparisons around stable node IDs, class/flid/object binding, editor descriptors, writing-system metadata, ghost state, focus order, accessibility identity, and migration diagnostics. + +#### Scenario: Semantic snapshot identifies fields and bindings +- **WHEN** a lexical entry view is captured +- **THEN** the semantic artifact SHALL include visible sections, field labels, editor kinds, object/class/flid or binding identity, writing-system metadata, visibility state, ghost state, expansion state, focus order, and accessibility identity where available + +#### Scenario: Legacy and Avalonia comparison reports meaningful differences +- **WHEN** legacy and Avalonia outputs are compared +- **THEN** the report SHALL distinguish missing semantic nodes, accepted visual variance, accessibility differences, timing differences, and unsupported migration gaps + +#### Scenario: Snapshot avoids incidental layout brittleness +- **WHEN** a visual layout difference does not alter stable semantic identity, editor kind, binding, focus order, accessibility identity, or accepted density thresholds +- **THEN** the parity result SHALL classify it as visual variance rather than a semantic regression + +### Requirement: Path 3 parity uses triangulated bundles + +Scenarios that judge visual fidelity for migrated Avalonia surfaces SHALL use a triangulated parity bundle rather than a single artifact lane. + +Each bundle SHALL contain: + +- a semantic snapshot keyed on stable node identity and binding, +- visual evidence for the legacy WinForms surface and the Avalonia surface, +- an image diff or equivalent visual-variance artifact, +- accessibility/workflow evidence for focus, invoke, popup reachability, and automation identity, +- a failure summary classifying the mismatch by lane. + +#### Scenario: Control-level Avalonia visual evidence uses headless rendering +- **WHEN** the parity target is an Avalonia control or region that can be evaluated without product-shell integration +- **THEN** the visual lane MAY use Avalonia.Headless rendered frames +- **AND** the evidence SHALL state that it is control-level visual parity, not desktop integration parity + +#### Scenario: Workflow parity still needs desktop automation +- **WHEN** the parity claim involves chooser dialogs, focus return, desktop automation trees, or shell-level interaction +- **THEN** the bundle SHALL include native desktop automation or live-window evidence +- **AND** an Avalonia.Headless result alone SHALL NOT satisfy the workflow/accessibility lane + +#### Scenario: Bundle failure classifies the broken lane +- **WHEN** a parity bundle fails +- **THEN** the failure summary SHALL distinguish whether the primary defect is semantic, visual/density, workflow/accessibility, performance, or an unsupported migration gap + +### Requirement: Migration gates include behavior matrices + +Each region proposed for Avalonia completion SHALL provide behavior matrices for undo/redo, LCModel transactions, keyboard/focus behavior, accessibility metadata, localization/resource identity, customer overrides, dynamic editor diagnostics, performance budgets, native-call instrumentation, and Graphite-free default behavior. + +#### Scenario: Missing behavior matrix blocks completion +- **WHEN** a region is proposed as completed +- **THEN** missing required behavior matrix evidence SHALL block completion even if visual rendering appears correct + +### Requirement: Failure artifacts are actionable + +Failed parity automation SHALL preserve enough evidence to diagnose the failing layer. + +#### Scenario: Parity failure emits bundled evidence +- **WHEN** a parity test fails +- **THEN** it SHALL write or reference the relevant trace log, semantic snapshot, screenshot or diff image, timing data, and root capability/scenario id + +### Requirement: Graphite decommissioning has validation artifacts + +The migration SHALL include validation artifacts proving Graphite is absent from the Avalonia default path and that affected projects/fonts receive actionable migration results. + +#### Scenario: Graphite-dependent fixture is detected +- **WHEN** a fixture contains Graphite-enabled writing-system settings, Graphite feature strings, or Graphite-only sample fonts +- **THEN** parity automation SHALL report a decommissioning diagnostic, replacement/migration status, or explicit unsupported legacy status + +#### Scenario: Gecko/PDF Graphite path is not part of Avalonia default +- **WHEN** preview, print, or PDF flows are validated for the default Avalonia Lexical Edit path +- **THEN** the artifacts SHALL prove they do not depend on Gecko Graphite rendering, `XWebBrowser` Graphite behavior, or `GeckofxHtmlToPdf` Graphite shaping assumptions diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-view-definition/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-view-definition/spec.md new file mode 100644 index 0000000000..53af6dd152 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-view-definition/spec.md @@ -0,0 +1,90 @@ +## ADDED Requirements + +### Requirement: Typed view definition is the canonical migration boundary + +The system SHALL define a managed typed view-definition model and Presentation IR for Lexical Edit that represents sections, fields, sequences, table regions, tree nodes, labels, visibility, ghost behavior, editor descriptors, writing-system metadata, OpenType/HarfBuzz font-feature metadata, stable node identity, localization/resource identity, accessibility identity, validation hints, focus groups, command affordances, and virtualization hints. + +#### Scenario: IR represents LexEntry layout semantics +- **WHEN** the LexEntry detail contract is compiled +- **THEN** the typed model SHALL include stable nodes for identity fields, morphology, senses, examples, references, custom fields, ghost add-first-item affordances, and nested sequences required by the parity checklist + +#### Scenario: IR is renderer independent +- **WHEN** a typed view-definition model is produced +- **THEN** it SHALL be consumable by semantic tests, legacy comparison adapters, and Avalonia renderers without exposing XML nodes or WinForms controls as the public contract + +### Requirement: XML Parts/Layout imports into the typed model during transition + +The system SHALL import existing XML Parts/Layout definitions, including override/unification behavior, into the typed view-definition model during the migration period. + +#### Scenario: Production XML imports with overrides +- **WHEN** a project uses shipped LexEntry Parts/Layout plus user overrides +- **THEN** the importer SHALL apply the same effective ordering and override/unification semantics as the legacy view resolution for covered constructs + +#### Scenario: Unsupported XML construct is explicit +- **WHEN** the importer encounters an XML construct not yet supported by the typed model +- **THEN** it SHALL emit a diagnostic tied to the layout part and node path rather than silently dropping the construct + +#### Scenario: Dynamic editor construct reports diagnostic +- **WHEN** XML import encounters dynamic editor strings, custom editor constructs, loader-based editors, fallback message/image slices, or unsupported launcher parameters +- **THEN** the importer SHALL preserve enough descriptor metadata for the legacy adapter when possible +- **AND** it SHALL emit a deterministic diagnostic with layout part, node path, editor key, and migration severity before Avalonia replacement is attempted + +### Requirement: View-definition services use dependency injection + +View-definition compilation and rendering SHALL depend on interfaces for layout source access, XML import, schema/model metadata, writing-system services, editor registry, cache, diagnostics, and LCModel access. + +#### Scenario: Compiler has replaceable services +- **WHEN** a unit test compiles a view definition +- **THEN** it SHALL be able to supply fake layout source, metadata, writing-system, editor registry, and diagnostics services without constructing WinForms controls + +### Requirement: Compilation is cacheable, deterministic, and off the UI thread + +Typed view-definition compilation SHALL be deterministic, cacheable by stable keys, cancellable, and runnable off the UI thread. + +Typed view-definition compilation SHALL operate on immutable layout, metadata, writing-system, custom-field, and override snapshots. It SHALL NOT depend on live WinForms controls, mutable `PropertyTable` state, or UI-thread-only cache traversal when compiling off the UI thread. + +#### Scenario: Warm compile reuses cache +- **WHEN** the same root class, layout id, project configuration fingerprint, writing-system profile, and override set are compiled twice +- **THEN** the second compile SHALL reuse the cached typed result + +#### Scenario: UI thread is not blocked by heavy compilation +- **WHEN** a Lexical Edit view opens or changes root layout +- **THEN** heavy XML import, custom-field expansion, and semantic compilation SHALL run outside the UI thread and support cancellation + +#### Scenario: Async compile does not touch UI state +- **WHEN** view-definition compilation runs asynchronously +- **THEN** it SHALL consume immutable inputs captured by a safe source service +- **AND** it SHALL publish results through the UI scheduling boundary only after compilation completes or is canceled + +### Requirement: XML retirement requires migration tooling and parity gates + +Runtime XML dependency SHALL be retired only after typed view-definition authoring/import/migration tooling and parity gates cover production layouts, user overrides, custom fields, ghost items, choosers, table views, and nested lexical structures. + +#### Scenario: XML retirement is blocked by uncovered behavior +- **WHEN** a covered production layout behavior cannot be represented in the typed view-definition model +- **THEN** runtime XML retirement SHALL remain blocked for that surface + +#### Scenario: Canonical view definition replaces XML at runtime +- **WHEN** a Lexical Edit surface has passed migration gates +- **THEN** the runtime UI SHALL load the canonical typed definition directly while retaining XML import only for migration/audit scenarios + +### Requirement: Typed view definitions replace native render contracts for completed regions + +Completed Avalonia regions SHALL use typed view definitions and managed renderer/editor services for display, measurement, selection metadata, hit testing, and editor realization instead of native Views/C++ viewing/rendering contracts. + +#### Scenario: View definition carries render semantics needed by Avalonia +- **WHEN** a typed view definition is produced for a migrated region +- **THEN** it SHALL include enough metadata for Avalonia controls to render, measure, virtualize, focus, and hit-test the region without consulting `IVwEnv`, RootBox, or native Views render objects + +#### Scenario: Missing native viewing/rendering replacement blocks completion +- **WHEN** the typed view-definition model cannot express behavior currently supplied only by native Views/C++ viewing/rendering +- **THEN** the region SHALL remain incomplete until a managed/Avalonia replacement service or an explicit scoped compatibility decision is added + +### Requirement: View definitions exclude Graphite runtime settings + +Canonical typed view definitions for Avalonia SHALL NOT depend on Graphite feature IDs, Graphite engine selection, or Graphite-only fonts at runtime. + +#### Scenario: Graphite settings become diagnostics or migration inputs +- **WHEN** XML import or project settings expose `IsGraphiteEnabled`, Graphite `DefaultFontFeatures`, or equivalent Graphite-only font metadata +- **THEN** the typed view-definition compiler SHALL emit migration diagnostics or mapped OpenType/HarfBuzz metadata +- **AND** it SHALL NOT preserve Graphite as an Avalonia runtime setting diff --git a/openspec/changes/lexical-edit-avalonia-migration/tasks.md b/openspec/changes/lexical-edit-avalonia-migration/tasks.md new file mode 100644 index 0000000000..2a899cbc76 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/tasks.md @@ -0,0 +1,109 @@ +# Tasks + +## 1. Migration Baseline and Spec Audit + +- [x] 1.1 Review Speckit artifacts against this OpenSpec change and keep `migration-map.md` current. +- [x] 1.2 Inventory current Lexical Edit view entry points: `RecordEditView`, `DataTree`, `SliceFactory`, XMLViews browse/table views, popup choosers, and AdvancedEntry Avalonia spike. +- [x] 1.3 Build a coverage map for DataTree refresh, SliceFactory/editor selection, launchers, popup choosers, XML table views, and render verification. +- [x] 1.4 Identify customer/user override XML fixtures that must be included before XML retirement. +- [x] 1.5 Start Graphite decommissioning inventory for writing-system settings, fonts, native render engines, Gecko/browser/PDF paths, tests, docs, sample assets, and build/package artifacts. +- [x] 1.6 Define migrated-region manifest format: entry points, allowed legacy adapters, forbidden symbols/call paths, custom linguistics service dependencies, parity fixtures, performance budgets, accessibility IDs, and rollback/default-switch gates. +- [x] 1.7 Freeze and maintain the seam capability docs `avalonia-edit-sessions`, `avalonia-undo-redo`, `avalonia-validation`, `avalonia-command-focus`, `avalonia-ui-scheduler`, `avalonia-lifetime`, and `seam-recommendations.md` as the reference playbook for both this change and the later shell change. + +## 2. Test Coverage Before Refactor + +- [x] 2.1 Add or extend unit/integration tests for DataTree refresh state transitions and postponed `PropChanged` behavior. +- [x] 2.2 Add or extend launcher pure-logic tests, prioritizing morph type swap/data-loss logic and chooser decision paths. +- [x] 2.3 Add semantic baseline capture for current DataTree/Slice output: labels, object/flid bindings, editor kind, visibility, expansion, focus order, and accessibility identity. +- [ ] 2.4 Add true UIA2/FlaUI/Appium smoke baselines for WinForms launcher/chooser workflows and XMLViews table header/filter reachability. The current branch has in-repo smoke substitutes only. (In-process accessibility/focus-order substitutes added in `DataTreeDisposalCharacterizationTests`; a true UIA2/FlaUI host still requires the running app and remains pending.) +- [x] 2.5 Add failure artifact bundling to render/parity tests where missing. (`RenderFailureArtifactBundler` in RenderVerification bundles received/diff images + `failure-summary.json` into a CI-discoverable folder; wired into `DataTreeRenderTests.VerifyDataTreeBitmap`; covered by `RenderFailureArtifactBundlerTests` (4 tests).) +- [x] 2.6 Add undo/redo and LCModel transaction characterization tests for editor replacement candidates. (`DataTreeUndoRedoCharacterizationTests`: CitationForm/Bibliography multistring edits revert/replay, multi-field single-task = single undo step, consecutive edits = distinct steps, slice reflects reverted model after reshow. Runs against real DataTree/Slice on net48.) +- [ ] 2.7 Add keyboard/IME, focus restoration, accessibility metadata, localization, and disposal/unsubscribe characterization tests for first-slice candidates. (Disposal/unsubscribe + accessibility-name + focus-order done in `DataTreeDisposalCharacterizationTests` (7 tests, real DataTree/Slice); keyboard/IME, focus restoration across refresh, and localization still pending and partly need a running app.) +- [x] 2.8 Add snapshot normalization rules so semantic baselines key on stable node IDs, class/flid/object binding, editor kind, writing-system metadata, ghost state, focus order, and accessibility identity instead of incidental layout noise. (Typed `ViewDefinitionModel.ToSnapshot` keys on stable IDs, field binding, editor classification, ws, visibility, expansion; ghost/a11y metadata deferred. `Src/Common/FwAvalonia/ViewDefinition`.) +- [ ] 2.9 Define the canonical Path 3 parity bundle for legacy baselines: semantic snapshot, matched WinForms screenshot(s), workflow/accessibility evidence, and a failure summary id shared across artifacts. + +## 3. Refactor Seams First + +- [x] 3.1 Introduce narrow DataTree service interfaces without changing behavior: `ILexicalRefreshCoordinator`, `IXCoreCommandBridge`, `IPropertyStateStore`, `IRecordNavigationContext`, diagnostics, writing-system access, and LCModel access. (Contracts defined in `Src/Common/FwAvalonia/Seams/ISeams.cs`; live `DataTree.cs` wiring is the regional step.) +- [x] 3.2 Extract refresh coordination into a testable service or state object while preserving current behavior. (`RefreshCoordinator` pure model of the LT-22414 DoNotRefresh/RefreshPending gate, with tests; live wiring deferred.) +- [x] 3.3 Put an `ILexicalEditorRegistry` boundary in front of `SliceFactory` so editor keys can resolve to legacy slices now and Avalonia editors later. (`LexicalEditorRegistry` with fallback-to-legacy + tests; live `SliceFactory` delegation deferred.) +- [x] 3.4 Extract at least one launcher humble object path, using morph type swap as the first target. (`MorphTypeSwapLogic` mirrors `MorphTypeAtomicLauncher.IsStemType` and the stem/affix data-loss decision, with tests; launcher delegation deferred.) +- [ ] 3.5 Define host/surface interfaces around `RecordEditView`/`DataTree` initialization, focus, context menus, and view replacement. (Partial: `IRegionLifetime` defined; full init/focus/context-menu/replacement surface still to do.) +- [x] 3.6 Extract edit-session and transaction seams for staged values, validation, cancellation, dirty state, undo/redo grouping, and LCModel commit behavior, following `avalonia-edit-sessions` and `avalonia-undo-redo`. (`IEditSession` + `PocEditSession` commit/cancel, with tests; LCModel-fenced impl deferred to the regional step.) +- [x] 3.7 Extract UI scheduling, focus navigation, command routing, and region lifetime/disposal seams before introducing editable Avalonia controls, following `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime`. (`IUiScheduler`/`ImmediateUiScheduler` and `IRegionLifetime`/`RegionLifetime` implemented + tested; `IXCoreCommandBridge` command/focus routing is contract-only.) +- [x] 3.8 Inventory dynamic editor strings and custom editor constructs (`custom`, `customwithparams`, `autocustom`, loader-based editors, fallback slices) with diagnostics requirements. (`EditorKindMap` classifies known/dynamic/obsolete/unknown faithfully to `SliceFactory`; importer raises per-node diagnostics, with tests.) + +## 4. Typed View Definition and XML Import + +- [x] 4.1 Define the typed view-definition model for sections, fields, sequences, tables, tree nodes, editor descriptors, visibility, ghost behavior, stable IDs, writing-system metadata, command affordances, validation hints, virtualization hints, localization/resource keys, and accessibility metadata. (Core model implemented: `ViewDefinitionModel`/`ViewNode` with kinds, editor classification, visibility, expansion, stable IDs, ws; richer metadata—ghost state, command/validation/virtualization hints, localization keys, full a11y—is deferred and explicitly extensible.) +- [x] 4.2 Implement or extend XML Parts/Layout import into typed view-definition/Presentation IR using existing Inventory/LayoutCache semantics where feasible. (`XmlLayoutImporter` + `IPartResolver`/`DictionaryPartResolver` parse the real layout/part schema: parts, grouping slices, obj/seq, indent, custom-field placeholders, visibility/expansion/label overrides.) +- [x] 4.3 Add deterministic snapshot tests for compiled IR from LexEntry detail layouts and selected override fixtures. (`ViewDefinitionTests` snapshot + determinism tests over CfAndBib, nested grouping, sequence/placeholder.) +- [x] 4.4 Add unsupported-construct diagnostics with layout part and node path. (Diagnostics for dynamic/unknown/obsolete editors, unresolved parts, unknown container/content, each carrying a stable node path.) +- [x] 4.5 Add cache key, invalidation, async compile, and cancellation tests. (`ViewDefinitionCacheKey` fingerprint, `ViewDefinitionCache`, `ViewDefinitionCompiler.CompileAsync` with `ViewDefinitionCompilerTests`.) +- [x] 4.6 Ensure off-thread compilation uses immutable layout, metadata, writing-system, custom-field, and override snapshots rather than live WinForms controls, `PropertyTable`, or cache mutation state. (`ViewDefinitionSourceSnapshot` captures immutable XML source; `CompileAsync` runs the importer off-thread over that snapshot only.) + +## 5. Graphite and Font Decommissioning + +- [ ] 5.1 Inventory and classify Graphite/native rendering code/assets: `Src/views/lib/GraphiteEngine.*`, `Src/views/lib/GraphiteSegment.*`, render-engine selection, Graphite feature UI/storage, sample/dist assets, package/build artifacts, and Graphite-specific tests/docs. +- [ ] 5.2 Inventory writing-system Graphite settings and persistence: `IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite`, import/export formats, project fixtures, and user-visible settings. +- [ ] 5.3 Classify Graphite feature UI/storage in writing-system dialogs and define OpenType/HarfBuzz replacements where supported, plus explicit diagnostics/rollback for unsupported Graphite-only settings. +- [ ] 5.4 Define replacement font/fallback policy for Graphite-only fonts, including project diagnostics and user-facing migration evidence. +- [ ] 5.5 Prove the migrated Avalonia default path has no unapproved runtime dependency on native Graphite render engines, Graphite-enabled legacy render selection, Gecko Graphite rendering, or unclassified Graphite-only feature settings. +- [ ] 5.6 Audit Gecko/XULRunner preview, print, and PDF paths: startup Graphite preference, `XWebBrowser` consumers, dictionary/interlinear/configuration previews, `GeckofxHtmlToPdf`, and `FieldWorksPdfMaker` packaging. +- [ ] 5.7 Select and validate a non-Graphite browser/PDF strategy for default Avalonia workflows, or explicitly leave affected paths outside the default Lexical Edit boundary. +- [ ] 5.8 Add validation proving Avalonia default readiness is blocked while any unapproved default-path Graphite/native-rendering dependency or unsupported Graphite-only setting remains. + +## 6. Avalonia Control Slices + +- [ ] 6.1 Replace/prove writing-system text display/editor foundation and simple scalar editors with FieldWorks-owned Avalonia controls over IR nodes. +- [ ] 6.2 Implement writing-system-aware text editor behavior using project font settings, flow direction, culture/script metadata, supported OpenType/HarfBuzz feature settings, and diagnostics for unsupported Graphite-only settings. +- [ ] 6.3 Implement popup/hover chooser controls using Avalonia flyouts/context menus and a service-backed chooser model. +- [ ] 6.4 Spike TreeView/tree-table rendering for multiple translations per sense/term, including compact multi-writing-system node templates. +- [ ] 6.5 Record any Avalonia package update or local/upstream control patch with parity justification and test evidence. +- [ ] 6.6 Add Avalonia.Headless tests for command shortcuts, popup focus return, validation errors, edit commit/cancel, keyboard/IME behavior, accessibility metadata, and disposal cleanup. +- [ ] 6.7 Add styling/resource and density token gates for shared `FwAvalonia` resources before broad editor rollout. +- [ ] 6.8 Make the first editable Avalonia slice satisfy `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local screen phase of `avalonia-command-focus` before expanding to more editors. +- [ ] 6.9 Add control-level visual parity capture for Avalonia using Avalonia.Headless with Skia-enabled rendered-frame capture, and stamp stable `AutomationProperties.Name`/`AutomationProperties.AutomationId` on user-facing controls used in Path 3 bundles. + +## 7. Tables, Slices, and Lexical Edit Migration + +- [ ] 7.1 Build a virtualized Avalonia table/browse view path over typed view definitions. +- [ ] 7.2 Compare legacy XMLViews table semantics against typed IR and Avalonia table semantics. +- [ ] 7.3 Migrate one representative vertical slice: LexEntry identity + morph type + one nested sense/gloss + chooser path. +- [ ] 7.4 Expand to core P0/P1 parity checklist items from the migrated Speckit parity list. +- [ ] 7.5 Gate full Lexical Edit replacement on semantic parity, UIA2 legacy baselines, Avalonia.Headless tests, render comparison evidence, native viewing/render seam audit evidence, and no unapproved Graphite/native-rendering default-path dependency. +- [ ] 7.6 Add a control-selection decision matrix for `TreeView`, `TreeDataGrid`, `ItemsRepeater`, and owned virtualized controls using density, virtualization, selection, accessibility, licensing/version, and multi-writing-system criteria. +- [ ] 7.7 Add large-fixture performance budgets for open time, scroll/expand latency, typing latency, realized control count, memory, and cache invalidation. +- [ ] 7.8 Produce Path 3 parity bundles for each first-slice and core parity fixture: WinForms visual evidence, Avalonia visual evidence, semantic snapshot, workflow/accessibility evidence, and an actionable failure summary. + +## 8. C++ Viewing/Render Seam Decommissioning + +- [ ] 8.1 Inventory all native Views/C++ viewing/rendering/editor dependencies reachable from the targeted Lexical Edit region, including `RootSite`, `IVwEnv`, RootBox/ViewSlice paths, `ManagedVwWindow`, measurement, selection, hit testing, scrolling, editor realization, and text rendering adapters. +- [ ] 8.2 Classify dependencies as baseline-only, non-migrated-region-only, custom linguistics service dependency, or blocker for the targeted migrated region. +- [ ] 8.3 Replace region-local C++ viewing/rendering/editor usage with managed/Avalonia services for text shaping, measurement, selection metadata, hit testing, scrolling, rendering, and editor realization. +- [ ] 8.4 Add tests or instrumentation proving the migrated region does not instantiate or call native Views/C++ viewing/rendering/editor infrastructure at runtime. +- [ ] 8.5 Remove or disable region-local native viewing/render adapters after replacement tests pass, while leaving shared native Views code available for non-migrated consumers. +- [ ] 8.6 Track any repo-wide native Views deletion blockers that remain outside the migrated Lexical Edit region. +- [ ] 8.7 Classify non-viewing native dependencies such as spell-check interop, parser tools, XAmple, Encoding Converters, ICU, Expat/ParserObject, and reg-free COM tooling as custom linguistics/service/tool dependencies unless they own display, layout, hit testing, selection, editor realization, or other Avalonia viewing behavior. +- [ ] 8.8 Define service seams for retained custom linguistics engines so Avalonia consumes results through managed contracts and never hosts their UI/render/editor infrastructure. + +## 9. XML Retirement Planning + +- [ ] 9.1 Design canonical post-XML view-definition authoring/storage format. +- [ ] 9.2 Build XML-to-typed-definition migration tooling and audit reports. +- [ ] 9.3 Prove migration on shipped LexEntry/LexSense layouts and selected user override fixtures. +- [ ] 9.4 Disable runtime XML for a gated migrated surface while retaining import/audit fallback. +- [ ] 9.5 Document remaining XML blockers, especially custom fields, ghost items, table views, choosers, TreeView-heavy views, and any remaining native viewing/render coupling. + +## 10. Validation + +- [ ] 10.1 Run targeted managed tests for changed areas using `./test.ps1` filters or relevant VS Code tasks. +- [ ] 10.2 Run render/parity baseline tests for affected surfaces. +- [ ] 10.3 Run native viewing/render seam audit tests/instrumentation for any region claimed as migrated. +- [ ] 10.4 Run Graphite/native-rendering default-path validation for any region proposed as default Avalonia UI. +- [ ] 10.5 Run browser/PDF replacement validation for default-path XHTML preview, print, or PDF flows. +- [ ] 10.6 Run `./build.ps1` before implementation work is considered ready for review. +- [ ] 10.7 Run `CI: Full local check` before commit/push. +- [ ] 10.8 Verify every migrated-region manifest has passing evidence for native-call instrumentation, no unapproved Graphite/native-rendering default-path dependency, undo/redo, accessibility, localization, keyboard/IME, customer override fixtures, performance budgets, and rollback behavior. +- [ ] 10.9 Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` instead of redefining those seams ad hoc during shell work. +- [ ] 10.10 Verify every scenario used to claim visual fidelity has a complete Path 3 bundle and that each bundle explicitly classifies which lanes are proven (`semantic`, `visual`, `workflow/accessibility`, `performance`) and which remain pending. diff --git a/openspec/changes/lexical-edit-avalonia-migration/view-inventory.md b/openspec/changes/lexical-edit-avalonia-migration/view-inventory.md new file mode 100644 index 0000000000..3ea69ad60f --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/view-inventory.md @@ -0,0 +1,68 @@ +# Lexical Edit View Inventory + +This inventory maps the current standard Lexical Edit stack and the AdvancedEntry Avalonia spike. It is intentionally source-backed: proposed seams are called out as proposed, not as existing implementation. AdvancedEntry prototype implementation files are split to `010-advanced-entry-preview-prototype`; this foundation branch keeps only the inventory and seam expectations. + +## 1. Legacy Edit Host + +| Component | Source | Role | Migration Risk | +|---|---|---|---| +| `RecordEditView` | [Src/xWorks/RecordEditView.cs](Src/xWorks/RecordEditView.cs) | xWorks edit pane that hosts the detail tree, participates in mediator/property-table routing, and handles refresh/selection context. | Shell integration, XCore commands, focus ownership, and record navigation must remain stable while a migrated region is hosted. | +| `DataTree` | [Src/Common/Controls/DetailControls/DataTree.cs](Src/Common/Controls/DetailControls/DataTree.cs) | WinForms slice host that expands XML layouts into row controls, listens for `PropChanged`, manages refresh, selection, scroll, and slice lifecycle. | High. It combines layout interpretation, refresh coordination, focus, WinForms controls, and LCModel notifications. | +| `Slice` and subclasses | [Src/Common/Controls/DetailControls/Slice.cs](Src/Common/Controls/DetailControls/Slice.cs) and sibling files | Base row abstraction for editors, launchers, labels, accessibility names, and chooser launch routing. | High. Editor realization and launcher behavior are distributed across many subclasses. | +| `SliceFactory` | [Src/Common/Controls/DetailControls/SliceFactory.cs](Src/Common/Controls/DetailControls/SliceFactory.cs) | Static factory that maps XML part/editor attributes and field metadata to concrete legacy slices. | Registry extraction must preserve fallback diagnostics, custom editors, and reuse-map behavior. | + +## 2. Legacy Layout and Override Sources + +| Component | Source | Role | Migration Risk | +|---|---|---|---| +| Parts/layout XML | `DistFiles/Language Explorer/Configuration/Parts` | Shipped `.fwlayout` and `*Parts.xml` definitions for LexEntry, LexSense, Morphology, lists, and custom field placeholders. | Typed IR import must preserve labels, field/flid binding, visibility, ghost behavior, writing-system metadata, and custom-field insertion. | +| `Inventory` / layout cache usage | `LayoutCache`, `Inventory`, and xWorks/XMLViews callers | Runtime merge and lookup of shipped and project-specific layout definitions. | Project override precedence and conflict resolution must be tested before XML retirement. | +| Dictionary/reversal configs | [Src/xWorks/DictionaryConfigurationMigrator.cs](Src/xWorks/DictionaryConfigurationMigrator.cs) and `DictionaryConfigurationMigrators` | Migrates legacy dictionary/reversal models and preserves user customizations. | Existing migration behavior is broad and customer-sensitive; selected fixtures must be carried into typed-definition parity tests. | +| CSS overrides | [Src/xWorks/CssGenerator.cs](Src/xWorks/CssGenerator.cs) | Generates and locates `ProjectDictionaryOverrides.css` / `ProjectReversalOverrides.css` for legacy preview/export styling. | Decide whether migrated Lexical Edit ignores, translates, or leaves CSS to legacy preview/export paths. | + +## 3. Browse and XMLViews Table Surfaces + +| Component | Source | Role | Migration Risk | +|---|---|---|---| +| `RecordBrowseView` | [Src/xWorks/RecordBrowseView.cs](Src/xWorks/RecordBrowseView.cs) | xWorks wrapper for browse/table views next to edit views. | Shell and list selection interactions can affect edit-view navigation. | +| `BrowseViewer` | [Src/Common/Controls/XMLViews/BrowseViewer.cs](Src/Common/Controls/XMLViews/BrowseViewer.cs) | Tabular browse controller for columns, filters, sorts, and selection. | Requires semantic and UI automation baselines before replacing with Avalonia table controls. | +| `XmlView` | [Src/Common/Controls/XMLViews/XmlView.cs](Src/Common/Controls/XMLViews/XmlView.cs) | Native Views-backed XML rendering site for table and preview surfaces. | Native Views dependency must be classified as baseline-only or blocker for any migrated default path. | + +## 4. Choosers and Launchers + +| Component | Source | Role | Migration Risk | +|---|---|---|---| +| `ReallySimpleListChooser` / `LeafChooser` | [Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs](Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs) | Modal chooser forms for flat and hierarchical selections. | Needs a testable chooser model and dialog service before Avalonia popup parity can be claimed. | +| `ChooserCommand` | [Src/Common/Controls/XMLViews/ChooserCommandBase.cs](Src/Common/Controls/XMLViews/ChooserCommandBase.cs) | Encapsulates chooser commit behavior. | Transaction and rollback semantics must be characterized. | +| `MorphTypeAtomicLauncher` | [Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs](Src/Common/Controls/DetailControls/MorphTypeAtomicLauncher.cs) | Complex morph-type swap workflow, data-loss prompts, form swaps, MSA replacement, refresh/focus side effects. | First humble-object extraction target; modal prompts currently block full pure-logic coverage. | + +## 5. AdvancedEntry Avalonia Spike + +| Component | Source | Current Role | Gaps Before Production Migration | +|---|---|---|---| +| Project root | Split branch | net8 Avalonia module and preview target. | Must stay preview-host friendly and detached from full app shell until shell migration. | +| Presentation IR | Split branch | Immutable node model for fields, objects, sequences, sections, visibility, and ghost metadata. | Needs first-class editor kind, writing-system metadata, stable accessibility IDs, and class/flid/object binding for full semantic normalization. | +| Layout compiler | Split branch | Compiles resolved XML layout contracts into Presentation IR. | Needs override fixtures, unsupported-construct diagnostics, cache invalidation, cancellation, and immutable metadata snapshots. | +| Parts loader | Split branch | Loads shipped parts/layout XML from selected directories. | Must consume merged default + project override inputs before XML retirement. | +| Edit session | Split branch | Prototype fenced LCModel undo-task session with `Save` and `Cancel`. | Docs and tests must not assume staged draft semantics until that implementation exists in the product migration path. | +| Property-grid prototype | Split branch | First-slice candidate for descriptors, lazy sequences, and staged views. | Needs accessibility, localization, focus, keyboard/IME, and validation presentation gates before production editing. | + +## 6. Hidden Dependency Checklist + +Before declaring any migrated Lexical Edit region default-ready, search and/or instrument for these dependencies: + +- WinForms controls: `DataTree`, `Slice`, `ViewSlice`, `RootSiteControl`, `BrowseViewer`, `XmlView`. +- Native Views/rendering: `IVwRootBox`, `IVwEnv`, `IVwGraphics`, `IRenderEngine`, `GraphiteEngineClass`, `UniscribeEngineClass`. +- Browser/PDF preview/export: `GeckoWebBrowser`, `XWebBrowser`, `GeckofxHtmlToPdf`, `FieldWorksPdfMaker`, PDF `--graphite` flags. +- Global COM/registration: FieldWorks must preserve registration-free COM; no migrated path may add global COM registration or registry hacks. +- Writing systems and fonts: `IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite`, Graphite-only font feature settings, custom font fallback. + +## 7. Inventory Acceptance Criteria + +An inventory entry is trustworthy only when it has: + +1. A source path that exists in the current branch, or an explicit split-branch marker when the source has been moved out of this branch. +2. Current/proposed status clearly marked. +3. Known callers or consumers searched when the surface is structural. +4. Tests or planned tests listed by behavior, not only by file name. +5. A risk classification: baseline-only, first-slice blocker, shell-phase blocker, or repo-wide cleanup. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/.openspec.yaml b/openspec/changes/lexical-edit-avalonia-poc-spike/.openspec.yaml new file mode 100644 index 0000000000..c53ef21aaa --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/design.md b/openspec/changes/lexical-edit-avalonia-poc-spike/design.md new file mode 100644 index 0000000000..634bcabb03 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/design.md @@ -0,0 +1,146 @@ +## Context + +The migration roadmap (`avalonia-migration-roadmap`) recommends a small proof-of-concept before the +regional Lexical Edit migration. FieldWorks runs on .NET Framework 4.8 with WinForms and native C++ +Views. The Avalonia preview prototype already exists on `010-advanced-entry-preview-prototype` but as +a **net8** module hosted out-of-process. The open question that blocks planning is whether Avalonia +can render an editable FieldWorks slice **in-process on net48**, selected beside the WinForms view by +a feature flag. + +Avalonia 11 targets `netstandard2.0`, so it can load on net48. Avalonia also ships +`WinFormsAvaloniaControlHost` (in `Avalonia.Win32.Interoperability`) to embed an Avalonia control +inside a WinForms control tree. This spike validates that path with the smallest possible slice. + +Relevant current constraints: +- `RecordEditView` hosts `DataTree`, which materializes `Slice` controls via `SliceFactory` from XML + Parts/Layout. The POC must sit beside this host without altering the default path. +- The seam decisions are already frozen in the lexical-edit change; the POC consumes them, it does + not redefine them. +- The semantic-baseline characterization test + (`DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder`) already proves + stable bindings and focus order for a two-field layout. The POC parity snapshot extends that format. + +## Goals / Non-Goals + +**Goals:** +- Prove (or disprove) in-process net48 Avalonia hosting beside WinForms. +- Prove near-pixel density/fidelity parity for one representative slice. +- Prove a default-off feature flag that switches the same build between WinForms and Avalonia. +- Produce measured evidence and a go/no-go for the regional migration. + +**Non-Goals:** +- Any default behavior change, native change, or broad UI replacement. +- Production hardening of undo/redo, accessibility, localization, or performance. + +## Decisions + +### 1. In-process net48 embedding is the primary host-bridge strategy + +**Decision:** Embed the Avalonia POC slice into the existing WinForms host using +`WinFormsAvaloniaControlHost` (Avalonia.Win32 interop) on net48. The out-of-process net8 preview host +from `010-advanced-entry-preview-prototype` is the documented fallback if in-proc embedding fails. + +**Rationale:** In-process embedding is the lowest-friction path to a flagged dual-run inside the +shipping product and directly answers the roadmap's host-bridge question. Out-of-process adds IPC, +lifetime, and focus complexity that should only be paid if embedding is proven unworkable. + +**Alternatives considered:** +- Out-of-process net8 host first: defers the in-proc question that the full migration depends on. +- Full net8 migration of the shell first: far too large for a POC and reverses the roadmap order. + +### 2. Two-adapter selection behind a default-off flag + +**Decision:** The host resolves a `LexicalEditSurface` at construction time from a feature flag +(`FW_AVALONIA_LEXEDIT` environment variable, with a `PropertyTable`/registry override), defaulting to +the WinForms surface. The Avalonia surface is only constructed when the flag is on. + +**Rationale:** A construction-time selection keeps both adapters fully isolated, makes the default +path identical to today, and matches the two-adapter pattern the regional migration will reuse. + +**Alternatives considered:** +- Compile-time `#if`: cannot run both from one build; fails the dual-run requirement. +- Per-field live toggling: unnecessary for a POC and complicates focus/commit semantics. + +### 3. The POC slice uses owned controls over three representative editor kinds + +**Decision:** The slice renders exactly three editors over the live `LexEntry`: +multi-writing-system **lexeme form** text, a **morph type** popup chooser, and one **sense gloss** +multi-writing-system text. Editing commits through the existing fenced LCModel edit-session model +(`avalonia-edit-sessions`), with control-local text undo as leaf behavior (`avalonia-undo-redo`). + +**Rationale:** These three cover the dominant Lexical Edit interaction classes (dense WS text and a +chooser flyout) while staying tiny. They exercise density, font/script behavior, and popup focus +return — the things the fidelity question is about. + +**Alternatives considered:** +- A single read-only label: too weak to answer the editing/commit and chooser questions. +- The full entry layout: not a spike; defeats the time-box and minimal-risk goal. + +### 4. Parity is measured semantically and by density, not by pixels + +**Decision:** Capture a normalized semantic snapshot (label, field, flid, editor kind, visibility, +focus order, accessibility name, writing-system metadata) for both the WinForms baseline and the +Avalonia slice, plus a density comparison (visible rows, label/editor column widths, line height) at +the same DPI. Differences are classified as accepted near-pixel variance, font/rendering variance, +missing data, or regression. + +**Rationale:** Pixel-perfect parity is an explicit non-goal; density and functional fidelity are +what matter. The semantic snapshot reuses the existing baseline test format so the POC plugs into the +regional parity automation. + +## Architecture + +```mermaid +flowchart TB + Flag["Feature flag
FW_AVALONIA_LEXEDIT (default off)
+ PropertyTable/registry override"]:::flag + Host["RecordEditView host
resolves surface at construction"]:::host + WF["WinForms surface (DEFAULT)
DataTree → SliceFactory → Slice
(unchanged)"]:::legacy + subgraph AVP["Avalonia POC surface (flag on)"] + Embed["WinFormsAvaloniaControlHost
(Avalonia.Win32, in-proc net48)"]:::bridge + Slice["Owned Avalonia POC slice"]:::future + E1["Lexeme form
multi-WS text editor"]:::future + E2["Morph type
popup chooser (flyout)"]:::future + E3["Sense gloss
multi-WS text editor"]:::future + end + ES["IEditSession (fenced LCModel txn)
commit / cancel"]:::port + LCM["LCModel (LexEntry data)"]:::model + Snap["Semantic + density parity snapshot
(WinForms vs Avalonia)"]:::test + + Flag --> Host + Host -->|default| WF + Host -->|flag on| Embed --> Slice + Slice --> E1 & E2 & E3 + E1 & E2 & E3 --> ES --> LCM + WF --> LCM + WF -.baseline.-> Snap + Slice -.candidate.-> Snap + + classDef flag fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef host fill:#e0f2fe,stroke:#0284c7,color:#082f49; + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef bridge fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; +``` + +## Risk controls (minimal-risk posture) + +- **Default unchanged:** flag defaults to WinForms; the Avalonia surface is never constructed unless + explicitly enabled. The spike cannot regress shipping behavior. +- **Isolated project:** all POC code and Avalonia package references live in a dedicated + `Src/Common/FwAvalonia/` project; the only edit to existing code is the guarded selection hook. +- **No native, no Graphite:** the slice must not instantiate native Views or Graphite; a headless + test asserts this. +- **Reversible:** removing the flag hook and the POC project fully reverts the spike. +- **Time-boxed:** if the in-process host bridge is not proven within the time box, record the failure + and switch to the documented out-of-process fallback rather than expanding scope. + +## Open questions (to be answered by the spike, not before) + +1. Does `WinFormsAvaloniaControlHost` initialize and render correctly under the FieldWorks net48 + startup, message loop, and DPI settings? +2. What is the measured density delta (rows visible, column widths, line height) at 100% and 150% DPI? +3. Does popup-chooser focus return correctly to the host on close? +4. Does a fenced LCModel commit from the Avalonia editor behave identically to the WinForms slice? diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/proposal.md b/openspec/changes/lexical-edit-avalonia-poc-spike/proposal.md new file mode 100644 index 0000000000..4170e13c72 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/proposal.md @@ -0,0 +1,69 @@ +## Why + +Before committing to the full Lexical Edit Avalonia migration (`lexical-edit-avalonia-migration`) +and the DataTree region split (`datatree-model-view-separation`), we need to de-risk the three +unknowns that dominate cost and feasibility: + +1. **Host bridge** — can an Avalonia-rendered editing surface run inside the existing + .NET Framework 4.8 FieldWorks process, selected at runtime beside the WinForms view, without a + second process or a net8 migration of the shell? +2. **Visual functional fidelity and density** — can an owned Avalonia editor reproduce the + information density, label/editor affordances, focus order, and writing-system text behavior of + the WinForms DataTree slices to *near-pixel* (not pixel-perfect) parity? +3. **Feature-flag dual-run** — can the new path be placed behind a flag so the same build runs + either Avalonia or the legacy WinForms controls, with a safe default of WinForms? + +This change is the **proof-of-concept spike** named in the migration roadmap +(`avalonia-migration-roadmap`). It is intentionally small, behind a default-off flag, and does not +change any default behavior. Its purpose is to produce evidence that converts the roadmap's +remaining estimates from guesses into measured numbers, then hand off to the regional migration. + +## What Changes + +- Add a runtime **feature flag** (default off) that selects between the existing WinForms Lexical + Edit surface and an Avalonia proof-of-concept surface for a single, narrow vertical slice. +- Build one **owned Avalonia editing slice** that renders three representative editors over the + current `LexEntry` data: a multi-writing-system **lexeme form** text editor, a **morph type** + popup chooser, and one **sense gloss** multi-writing-system text editor. +- Host the Avalonia slice **in-process on net48** beside the WinForms `RecordEditView`, evaluating + `WinFormsAvaloniaControlHost` (Avalonia.Win32 interop) as the primary host-bridge strategy, with + an out-of-process net8 preview host as the documented fallback if in-proc embedding fails. +- Reuse the existing seam decisions (`avalonia-edit-sessions`, `avalonia-undo-redo`, + `avalonia-validation`, `avalonia-command-focus`, `avalonia-ui-scheduler`, `avalonia-lifetime`) at + the minimum level needed to make the slice editable and committable, without reopening them. +- Capture a **semantic + density parity snapshot** comparing the WinForms baseline and the Avalonia + slice for the same entry, using the normalized snapshot format introduced by the parity tests. +- Produce a short **spike evidence report** with host-bridge findings, a density/fidelity comparison, + edit-commit/cancel behavior, and a go/no-go recommendation for the regional migration. + +## Non-goals + +- Replacing or removing any WinForms control, DataTree, Slice, SliceFactory, or native Views code. +- Making Avalonia the default for any surface. The flag defaults to WinForms. +- Implementing the full typed view-definition IR, XML import pipeline, or XML retirement. +- Graphite or native-rendering decommissioning beyond confirming the POC slice does not depend on + Graphite or native Views at runtime. +- Migrating tables/browse views, nested sequences, custom fields, ghost items, or choosers beyond + the single morph type chooser in the slice. +- Production-quality undo/redo, accessibility, localization, or performance hardening. The spike + records gaps; it does not close them. +- Pixel-perfect parity. The target is near-pixel parity with equivalent density and interaction. + +## Capabilities + +### New Capabilities + +- `lexical-edit-avalonia-poc-spike`: Time-boxed, flag-gated, in-process Avalonia proof-of-concept for + one Lexical Edit vertical slice, with host-bridge, parity, and dual-run evidence requirements. + +## Impact + +- Managed code: new `Src/Common/FwAvalonia/` (POC host + slice) and a small flag/selection hook in + `Src/xWorks/RecordEditView.cs` (or its host) guarded so default behavior is unchanged. No native + (C++) changes. +- Tests: `Src/Common/Controls/DetailControls/DetailControlsTests/` (semantic snapshot baseline the + POC must match) and new Avalonia.Headless tests for the POC slice. +- Dependencies: may add `Avalonia`, `Avalonia.Win32` / `WinFormsAvaloniaControlHost`, and + `Avalonia.Headless` package references behind the POC project only; no change to default packaging. +- Build/packaging: the POC project builds but the flag default keeps it out of the default runtime + path; installer/packaging is unchanged for this spike. diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/specs/lexical-edit-avalonia-poc-spike/spec.md b/openspec/changes/lexical-edit-avalonia-poc-spike/specs/lexical-edit-avalonia-poc-spike/spec.md new file mode 100644 index 0000000000..331882dc98 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/specs/lexical-edit-avalonia-poc-spike/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: The POC spike never changes default behavior + +The proof-of-concept SHALL be gated by a feature flag that defaults to the existing WinForms Lexical +Edit surface. The Avalonia surface SHALL only be constructed when the flag is explicitly enabled. + +#### Scenario: Default build runs WinForms unchanged +- **WHEN** FieldWorks runs without the POC flag set +- **THEN** the Lexical Edit surface SHALL be the existing WinForms `DataTree`/`Slice` path +- **AND** no Avalonia runtime, host, or POC slice SHALL be constructed + +#### Scenario: Flag enables the Avalonia POC slice +- **WHEN** the `FW_AVALONIA_LEXEDIT` flag (or its `PropertyTable`/registry override) is enabled +- **THEN** the host SHALL construct the Avalonia POC surface for the target slice instead of the + WinForms surface for that slice +- **AND** the same build SHALL be able to run either surface without recompilation + +### Requirement: The POC proves an in-process host bridge or records the fallback + +The spike SHALL attempt in-process embedding of the Avalonia slice into the WinForms host on +.NET Framework 4.8 first, and SHALL record measured evidence of success or the documented fallback. + +#### Scenario: In-process embedding is proven +- **WHEN** the in-process host-bridge tasks complete +- **THEN** the evidence report SHALL state whether `WinFormsAvaloniaControlHost` rendered, sized, and + received focus correctly under the FieldWorks net48 startup at 100% and 150% DPI + +#### Scenario: In-process embedding fails within the time box +- **WHEN** in-process embedding cannot be proven within the spike time box +- **THEN** the spike SHALL record the failure and switch to the out-of-process net8 preview-host + fallback rather than expanding scope + +### Requirement: The POC slice reproduces functional fidelity and density, not pixels + +The Avalonia POC slice SHALL reproduce the WinForms baseline's labels, editor affordances, focus +order, writing-system text behavior, and information density to near-pixel tolerance. + +#### Scenario: Three representative editors are present +- **WHEN** the Avalonia POC slice is shown for a `LexEntry` +- **THEN** it SHALL render a multi-writing-system lexeme-form editor, a morph type popup chooser, and + one sense-gloss multi-writing-system editor over the live LCModel data + +#### Scenario: Parity is captured semantically and by density +- **WHEN** the POC slice is compared to the WinForms baseline +- **THEN** a normalized semantic snapshot (label, field, flid, editor kind, visibility, focus order, + accessibility name, writing-system metadata) SHALL be captured for both surfaces +- **AND** density measurements (visible rows, label/editor column widths, line height) SHALL be + captured at 100% and 150% DPI +- **AND** every difference SHALL be classified as accepted near-pixel variance, font/rendering + variance, missing data, or regression + +### Requirement: The POC slice has no native viewing or Graphite dependency + +The Avalonia POC slice SHALL NOT instantiate or call native Views/C++ display, layout, measurement, +hit testing, selection, or editor-realization code, nor Graphite render engines, at runtime. + +#### Scenario: Headless test asserts no native/Graphite dependency +- **WHEN** the Avalonia.Headless POC test renders the slice and commits an edit +- **THEN** it SHALL pass without instantiating native Views or Graphite render engines + +### Requirement: Editing commits through the fenced LCModel edit session + +The POC slice SHALL commit and cancel edits through the existing fenced LCModel edit-session model, +with control-local text undo allowed only as subordinate leaf behavior. + +#### Scenario: Commit and cancel match the WinForms slice +- **WHEN** a user edits the lexeme form or sense gloss in the Avalonia POC slice and commits +- **THEN** the LCModel state SHALL match the result of the equivalent WinForms edit +- **AND** cancelling SHALL leave the LCModel state unchanged + +### Requirement: The spike ends with evidence and a go/no-go + +The spike SHALL conclude with a written evidence report that updates the roadmap estimates and gives +an explicit recommendation for the regional Lexical Edit migration. + +#### Scenario: Evidence report exists before handoff +- **WHEN** the spike is considered complete +- **THEN** `spike-evidence.md` SHALL record the host-bridge result, density/fidelity comparison, + edit-commit/cancel behavior, defects, and a go/no-go for `datatree-model-view-separation` and + `lexical-edit-avalonia-migration` diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md b/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md new file mode 100644 index 0000000000..98d69b63e2 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md @@ -0,0 +1,100 @@ +# POC Spike Evidence + +Change: `lexical-edit-avalonia-poc-spike` +Date: 2026-06-08 +Branch: `010-advanced-entry-view-phase-1-2` + +This report records what the spike actually proved with executed evidence, and what remains. It is +the artifact required by tasks 5.1–5.2 and the spec requirement "The spike ends with evidence and a +go/no-go." + +## Environment + +- OS: Windows, x64 +- .NET SDK: 9.0.314 +- Target framework under test: **net48** (.NET Framework 4.8) +- Avalonia: **11.3.17** (pinned in `Directory.Packages.props`) + +## Host-bridge package decision (executed analysis) + +- **Avalonia 12.x dropped `netstandard2.0`** (net8+ only) and therefore cannot load on net48. +- **Avalonia 11.3.17** core/desktop/headless ship `netstandard2.0`, so they load on net48. +- `Avalonia.Win32.Interoperability` (which provides `WinFormsAvaloniaControlHost` for in-process + embedding) targets `.NETFramework4.6.1` — a strong positive signal for in-process net48 hosting. +- Conclusion: the net48 in-process strategy is viable **only on the Avalonia 11.3.x line**. This is + now pinned and documented in CPM. + +## What was built and executed + +Two isolated projects were created under `Src/Common/FwAvalonia/` (intentionally **not** added to +`FieldWorks.proj` or `FieldWorks.sln` yet, so the spike cannot break the default build): + +- `FwAvalonia.csproj` — net48 library: flag resolver/factory, POC DTOs, fenced edit session, and + pure-C# Avalonia controls (multi-WS text editor, morph-type popup chooser, assembled slice). +- `FwAvaloniaTests.csproj` — net48 headless test project (Avalonia.Headless.NUnit). + +### Executed results + +| Step | Command | Result | +|------|---------|--------| +| Restore (Avalonia 11.3.17 on net48) | `dotnet restore FwAvalonia.csproj` | **Succeeded** (3.4s) | +| Build library on net48 | `dotnet build FwAvalonia.csproj -c Debug` | **Build succeeded, 0 errors** | +| Build headless tests on net48 | `dotnet build FwAvaloniaTests.csproj -c Debug` | **Build succeeded, 0 errors** | +| Run headless tests on net48 | `dotnet test FwAvaloniaTests.csproj` | **Passed! 20 passed, 0 failed (1s)** | + +The 20 passing tests cover: + +- **Flag / dual-run (8):** resolver defaults to WinForms; selects Avalonia only on a truthy flag + (`1/true/on/yes`); stays WinForms on falsy/unknown; explicit override beats the environment; + the factory **does not construct any Avalonia runtime when the flag is off**, and constructs it + exactly once when on. +- **No native/Graphite (1):** the POC assembly references no `Graphite`, `ViewsInterfaces`, `Views`, + `RootSite`, `Gecko`, or `Geckofx` assembly. +- **Headless Avalonia slice on net48 (5):** renders the three editors (lexeme form, morph-type + chooser, sense gloss); writing-system text uses the configured font per alternative; an edit writes + through to the entry and survives **commit** while a later edit is rolled back by **cancel**; the + morph-type chooser updates the entry and **returns focus to the host button**; the semantic + snapshot is deterministic. + +## Mapping to spike tasks + +| Task group | Status | Evidence | +|------------|--------|----------| +| 1. Feature flag and two-adapter selection (default off) | **Done** | `LexicalEditSurfaceResolver`, `LexicalEditSurfaceFactory`, 8 passing tests. | +| 2. In-process host bridge | **Partial — foundation proven** | Avalonia 11.3.17 restores/builds/runs **headless on net48**; `Avalonia.Win32.Interoperability` (net461) restores/builds on net48. Embedding `WinFormsAvaloniaControlHost` into the live `RecordEditView` is **not yet done** (needs the running app) — see Pending. | +| 3. Owned Avalonia POC slice (three editors) | **Done (headless)** | `PocLexEntrySlice` + 5 passing headless tests, incl. commit/cancel and popup focus return. | +| 4. Parity and dual-run evidence | **Partial** | Deterministic semantic snapshot captured in headless tests; DPI density measurement and side-by-side screenshots require the running app — see Pending. | +| 5. Spike report and handoff | **This document** | Go/no-go below. | + +## Pending (honest gaps — not yet executed) + +These require the full FieldWorks app to build and run, which is heavier than this isolated spike and +was intentionally deferred to keep the default build safe: + +1. **In-process embedding into `RecordEditView`** via `WinFormsAvaloniaControlHost` under the live + net48 message loop and DPI settings (task 2.3). The package-level feasibility is proven; the live + embedding is not. +2. **DPI density measurement** at 100% and 150% and **side-by-side screenshots** of flag-off vs + flag-on in the running app (tasks 0.2, 4.2, 4.3). +3. **Avalonia.Headless render-frame** native/Graphite runtime assertion beyond the reference audit + (task 4.4 is covered at the reference level; a rendered-frame assertion is not added). + +## Go / No-Go + +**GO** for the regional migration, with the in-app embedding step (Pending #1) as the first task of +the DataTree region (`datatree-model-view-separation`). + +Rationale: the dominant unknown — *can Avalonia render editable FieldWorks-owned controls in-process +on .NET Framework 4.8?* — is answered **yes** at the framework/build/headless level, on a pinned, +netstandard2.0-compatible Avalonia line, with the WinForms interop assembly confirmed to restore and +build on net48. The two-adapter flag works and keeps WinForms the safe default. The remaining risk is +concentrated in the live-embedding and density-measurement steps, which are now small, well-scoped, +and gated by Gate 0. + +## Handoff to the regional migration + +- `SliceSpec` (Plan A) ⊂ typed view-definition node (Plan B); `IDataTreeView` (Plan A) is selected by + this spike's two-adapter flag. The first regional task is to embed the proven slice into + `RecordEditView` behind the flag and capture the DPI density evidence. +- Keep the spike projects isolated until Gate 0's live-embedding evidence is captured, then add them + to `FieldWorks.proj` and `FieldWorks.sln` per `avalonia.instructions.md`. diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/tasks.md b/openspec/changes/lexical-edit-avalonia-poc-spike/tasks.md new file mode 100644 index 0000000000..259f9305de --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/tasks.md @@ -0,0 +1,87 @@ +# Tasks + +> Spike posture: keep the default path unchanged, keep all new code isolated, and stop at evidence. +> Each task is independently testable. No native (C++) work. Tasks ordered for minimal risk. + +## 0. Pre-spike baseline (no new runtime code) + +- [ ] 0.1 Confirm the WinForms baseline is green: run the DetailControls semantic-baseline tests + (`DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` and the new + `SemanticSnapshot_*` tests) via `./test.ps1` filter. +- [ ] 0.2 Capture the WinForms density baseline for the target slice (lexeme form + morph type + + one sense gloss) at 100% and 150% DPI: visible rows, label/editor column widths, line height. +- [ ] 0.3 Record the exact bindings the POC must reproduce (class/flid/object, editor kind, focus + order, accessibility name) from the semantic snapshot helper. + +## 1. Feature flag and two-adapter selection (default off) + +- [x] 1.1 Add a `LexicalEditSurface` selection resolved at host construction from + `FW_AVALONIA_LEXEDIT` (env) with a `PropertyTable`/registry override; default = WinForms. + (`LexicalEditSurfaceResolver`.) +- [ ] 1.2 Add a guarded hook in `RecordEditView` (or its host) that constructs the Avalonia surface + only when the flag is on; verify the default path is byte-for-byte unchanged when off. + (Deferred to the regional migration's live-embedding step; see spike-evidence.md Pending #1.) +- [x] 1.3 Add a unit test proving the resolver returns WinForms by default and Avalonia only when the + flag is explicitly set. (8 passing flag/factory tests.) + +## 2. In-process host bridge (primary strategy) + +- [x] 2.1 Add an isolated `Src/Common/FwAvalonia/` project with `Avalonia`, `Avalonia.Win32` + (`WinFormsAvaloniaControlHost`), and `Avalonia.Headless` references; do not touch default packaging. + (Both projects restore/build on net48; Avalonia 11.3.17 pinned because 12.x dropped netstandard2.0.) +- [x] 2.2 Initialize the Avalonia app/runtime once, safely, under net48 (lifetime per + `avalonia-lifetime`, dispatch per `avalonia-ui-scheduler`). (`PocApp`/`PocAvaloniaHost`; proven + under the headless platform.) +- [ ] 2.3 Embed an empty Avalonia control via `WinFormsAvaloniaControlHost` inside the host and prove + it renders, sizes, and receives focus at 100% and 150% DPI. (Package-level feasibility proven on + net48; live embedding deferred to the regional migration — spike-evidence.md Pending #1.) +- [ ] 2.4 If 2.2–2.3 fail within the time box, record findings and switch to the documented + out-of-process net8 preview-host fallback; do not expand scope. (Not needed: in-proc path viable.) + +## 3. Owned Avalonia POC slice (three editors) + +- [x] 3.1 Build a multi-writing-system lexeme-form text editor over the live `LexEntry`, using + project writing-system font settings and OpenType/HarfBuzz shaping (no Graphite, no native Views). + (`MultiWsTextEditor`; over a detached DTO for the headless spike.) +- [x] 3.2 Build a morph type popup chooser (Avalonia flyout/context menu) backed by a chooser model; + prove focus returns to the host on close (`avalonia-command-focus`). (`MorphTypePopupChooser`; + focus-return test passes.) +- [x] 3.3 Build one sense-gloss multi-writing-system text editor over the live `LexEntry`. + (`SenseGlossEditor`; over a detached DTO for the headless spike.) +- [x] 3.4 Wire editing through the fenced LCModel edit session (`avalonia-edit-sessions`) with + control-local text undo as leaf behavior (`avalonia-undo-redo`); commit and cancel both work. + (`PocEditSession`; commit/cancel test passes.) +- [x] 3.5 Apply density tokens so label/editor columns, line height, and spacing match the WinForms + baseline within the near-pixel tolerance. (`PocDensity`; measured DPI comparison pending — see 4.2.) + +## 4. Parity and dual-run evidence + +- [ ] 4.1 Capture a normalized semantic snapshot of the Avalonia slice in the same format as the + WinForms baseline and diff them; classify every difference. (POC-side deterministic snapshot + captured in headless tests; cross-surface diff with the WinForms baseline pending the live app.) +- [ ] 4.2 Capture the Avalonia density measurements at 100% and 150% DPI and compare to task 0.2. + (Pending the running app — spike-evidence.md Pending #2.) +- [ ] 4.3 Run the same build twice — flag off (WinForms) and flag on (Avalonia) — and confirm both + load, edit, and commit the same entry; capture screenshots of each for the evidence report. + (Pending the running app — spike-evidence.md Pending #2.) +- [x] 4.4 Add an Avalonia.Headless test asserting the slice renders the three editors, returns popup + focus, and commits/cancels an edit without instantiating native Views or Graphite. + (5 headless tests + reference audit; rendered-frame native assertion noted as a follow-up.) + +## 5. Spike report and handoff + +- [x] 5.1 Write `spike-evidence.md`: host-bridge result (primary or fallback), density/fidelity + comparison with classified diffs, edit-commit/cancel behavior, and any defects found. +- [x] 5.2 Record measured numbers that update the roadmap estimates (host-bridge feasibility, density + delta, per-editor effort) and give an explicit go/no-go for the regional migration. (GO.) +- [x] 5.3 Map the proven POC seams back to `datatree-model-view-separation` (SliceSpec ⊂ typed IR, + `IDataTreeView` selected by the two-adapter flag) so the regional work starts from this evidence. + +## 6. Validation + +- [x] 6.1 Run targeted managed tests for changed areas via `./test.ps1` filters. (Isolated spike run + via `dotnet test FwAvaloniaTests.csproj`: 20 passed, 0 failed.) +- [x] 6.2 Run the Avalonia.Headless POC tests. (Included in the 20 passing tests.) +- [ ] 6.3 Run `./build.ps1` and confirm the default (flag off) runtime path is unchanged. (Spike + projects are isolated from the traversal; default build is unaffected by construction.) +- [ ] 6.4 Run `CI: Full local check` before commit/push. diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/test-plan.md b/openspec/changes/lexical-edit-avalonia-poc-spike/test-plan.md new file mode 100644 index 0000000000..e843acfece --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/test-plan.md @@ -0,0 +1,78 @@ +# POC Spike Test Plan + +This plan defines the tests that establish the WinForms baseline the POC must match, and the +Avalonia.Headless tests that prove the POC slice. It is intentionally small and reuses the existing +DetailControls characterization harness. + +## Test layers + +| Layer | Project | Runs against | Purpose | +|-------|---------|--------------|---------| +| Semantic baseline | `DetailControlsTests` | WinForms `DataTree`/`Slice` | Lock the bindings, editor kinds, and focus order the POC must reproduce. | +| Density baseline | manual + screenshot evidence | WinForms `RecordEditView` | Capture rows/column widths/line height at 100% and 150% DPI. | +| Flag/selection | new POC unit test | surface resolver | Prove default = WinForms, Avalonia only when flag on. | +| Avalonia slice | Avalonia.Headless | POC slice | Prove three editors, popup focus return, commit/cancel, no native/Graphite. | +| Dual-run | manual + screenshot evidence | full app, same build | Prove flag off vs on both load/edit/commit the same entry. | + +## 1. WinForms semantic baseline (already runnable today) + +These run against existing code and ship in this change so the baseline is locked before any POC code +exists. They extend the existing +`DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder`. + +- **`SemanticSnapshot_CfAndBib_IsStableAndCapturesPocBaseline`** + - Realize the `CfAndBib` layout, build a normalized semantic snapshot string + (`#order | label | field | flid | editor | visibility | a11y`), and assert it matches the values + already proven by the existing baseline test. + - Realize a second time and assert the snapshot is byte-for-byte identical (determinism), which is + the property the Avalonia parity comparison relies on. + +Rationale: every asserted value is already proven by the existing passing baseline test, so the new +test adds a reusable normalized-snapshot format with no new brittle expectations. + +## 2. Flag / two-adapter selection + +- **`Surface_DefaultsToWinForms_WhenFlagUnset`** — resolver returns the WinForms surface when no flag + is set. +- **`Surface_SelectsAvalonia_WhenFlagEnabled`** — resolver returns the Avalonia surface only when + `FW_AVALONIA_LEXEDIT` (or the `PropertyTable`/registry override) is enabled. +- **`Surface_FlagOff_ConstructsNoAvaloniaRuntime`** — with the flag off, no Avalonia app/host/slice is + constructed (assert via a construction counter or guard). + +## 3. Avalonia.Headless slice tests + +- **`PocSlice_RendersThreeEditors`** — lexeme form, morph type chooser entry point, and sense gloss are + present and bound to the live `LexEntry`. +- **`PocSlice_MorphTypeChooser_ReturnsFocusToHost`** — opening then closing the chooser flyout returns + focus to the host editor. +- **`PocSlice_CommitWritesLcmAndCancelLeavesUnchanged`** — editing then committing updates LCModel to + match the equivalent WinForms edit; cancelling leaves LCModel unchanged. +- **`PocSlice_DoesNotInstantiateNativeViewsOrGraphite`** — rendering and committing complete without + constructing native Views or Graphite render engines (assert via instrumentation/guard). +- **`PocSlice_WritingSystemText_UsesProjectFontSettings`** — each writing-system alternative uses the + configured font family/size/flow direction and OpenType feature settings. + +## 4. Density / fidelity evidence (measured, not asserted) + +Captured into `spike-evidence.md`, not as pass/fail unit tests, because the target is near-pixel: + +- Visible rows for the slice at 100% and 150% DPI (WinForms vs Avalonia). +- Label column width, editor column width, and line height (WinForms vs Avalonia). +- Side-by-side screenshots of the same entry under flag off and flag on. +- A classification table: accepted near-pixel variance, font/rendering variance, missing data, + regression. + +## 5. Validation commands + +- `./test.ps1` filtered to `DetailControlsTests` for the semantic baseline. +- `./test.ps1` filtered to the new POC test project for flag/headless tests. +- `./build.ps1` then launch with the flag off to confirm the default path is unchanged. +- `CI: Full local check` before commit/push. + +## Exit criteria + +- Section 1 and Section 2 tests pass. +- Section 3 headless tests pass (or the documented out-of-process fallback is in use with equivalent + evidence). +- Section 4 evidence is captured at both DPIs with classified diffs. +- `spike-evidence.md` records a go/no-go. diff --git a/scripts/Agent/Run-AvaloniaPreview.ps1 b/scripts/Agent/Run-AvaloniaPreview.ps1 new file mode 100644 index 0000000000..806d9a04ff --- /dev/null +++ b/scripts/Agent/Run-AvaloniaPreview.ps1 @@ -0,0 +1,84 @@ +<# +.SYNOPSIS + Builds and launches the FieldWorks Avalonia Preview Host. + +.DESCRIPTION + Provides a fast way to view Avalonia modules without running the full FieldWorks app. + Modules register via [assembly: FwPreviewModule(...)] and may optionally provide sample data. + +.EXAMPLE + .\scripts\Agent\Run-AvaloniaPreview.ps1 + +.EXAMPLE + .\scripts\Agent\Run-AvaloniaPreview.ps1 -Module lexical-edit-poc -Data sample +#> + +[CmdletBinding()] +param( + [string]$Module = "lexical-edit-poc", + [ValidateSet("empty", "sample")] + [string]$Data = "empty", + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug", + [switch]$BuildOnly +) + +$ErrorActionPreference = 'Stop' + +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$projectPath = Join-Path $repoRoot "Src\Common\FwAvaloniaPreviewHost\FwAvaloniaPreviewHost.csproj" + +$helpersPath = Join-Path $repoRoot "Build\Agent\FwBuildHelpers.psm1" +if (-not (Test-Path $helpersPath)) { + throw "FwBuildHelpers.psm1 not found at $helpersPath" +} +Import-Module $helpersPath -Force + +Initialize-VsDevEnvironment +Test-CvtresCompatibility +$env:arch = 'x64' + +Write-Host "Restoring Avalonia Preview Host ($Configuration)..." -ForegroundColor Cyan +Invoke-MSBuild -Arguments @( + $projectPath, + '/t:Restore', + "/p:Configuration=$Configuration", + '/p:Platform=x64', + '/v:minimal', + '/nologo' +) -Description 'FwAvaloniaPreviewHost (Restore)' + +Write-Host "Building Avalonia Preview Host ($Configuration)..." -ForegroundColor Cyan +Invoke-MSBuild -Arguments @( + $projectPath, + '/t:Build', + "/p:Configuration=$Configuration", + '/p:Platform=x64', + '/v:minimal', + '/nologo' +) -Description 'FwAvaloniaPreviewHost (Build)' + +$exeCandidates = @( + (Join-Path $repoRoot "Src\Common\FwAvaloniaPreviewHost\bin\$Configuration\net48\FwAvaloniaPreviewHost.exe"), + (Join-Path $repoRoot "Output\$Configuration\FwAvaloniaPreviewHost.exe") +) + +$exePath = $exeCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $exePath) { + throw "Preview host exe not found after build. Checked: $($exeCandidates -join '; ')" +} + +if ($BuildOnly) { + Write-Host "[OK] Build complete (BuildOnly)." -ForegroundColor Green + exit 0 +} + +Write-Host "Launching: $exePath" -ForegroundColor Cyan +Push-Location $repoRoot +try { + & $exePath --module $Module --data $Data +} +finally { + Pop-Location +} +exit $LASTEXITCODE From 5eb7c51759017f77a987807d2a2aa43880943e82 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 10:41:09 -0400 Subject: [PATCH 02/14] Updates Remove build flag Updates Add testing Phases 3+4 Refined plan Update plan. --- .../skills/fieldworks-avalonia-ui/SKILL.md | 14 +- .../fieldworks-localization-review/SKILL.md | 27 ++ .../SKILL.md | 8 +- .../SKILL.md | 2 + .../fieldworks-ui-wiring-review/SKILL.md | 29 ++ .../fieldworks-uia2-parity-testing/SKILL.md | 4 +- .../SKILL.md | 14 +- Build/RegFree.targets | 1 - Directory.Packages.props | 3 +- .../MorphTypeAtomicLauncherTests.cs | 33 ++ Src/Common/Controls/XMLViews/FilterBar.cs | 60 ++- Src/Common/FieldWorks/BuildInclude.targets | 1 - .../LexicalEditSurfaceResolverTests.cs | 47 +-- .../FwAvaloniaTests/PocLexEntrySliceTests.cs | 30 ++ .../FwAvaloniaTests/RegionModelTests.cs | 170 ++++++++ .../SurfaceAndHostContractTests.cs | 140 +++++++ .../ViewDefinitionMetadataTests.cs | 92 +++++ .../FwAvalonia/LexicalEditSurfaceResolver.cs | 45 +- .../LexicalEditSurfaceSelectionService.cs | 87 ++++ .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 17 +- .../Region/LexicalEditRegionMapper.cs | 91 +++++ .../Region/LexicalEditRegionModel.cs | 148 +++++++ .../Region/LexicalEditRegionView.cs | 167 ++++++++ .../FwAvalonia/Seams/ActiveHostContract.cs | 66 +++ Src/Common/FwAvalonia/Seams/IHostSurface.cs | 76 ++++ .../ViewDefinition/ViewDefinitionModel.cs | 61 ++- .../ViewDefinition/XmlLayoutImporter.cs | 32 +- .../FwUtils/Properties/Settings.Designer.cs | 22 +- Src/LexText/LexTextControls/LexOptionsDlg.cs | 59 ++- .../LexTextControls/LexTextControls.resx | 58 +-- .../LexOptionsDlgTests.cs | 282 +++++++++++++ Src/xWorks/LexicalEditRegionBuilder.cs | 139 +++++++ Src/xWorks/RecordEditView.cs | 78 ++-- Src/xWorks/xWorksTests/BulkEditBarTests.cs | 13 + .../RecordEditViewActiveHostContractTests.cs | 159 ++++++++ .../xWorksTests/RecordEditViewSwitchTests.cs | 213 ++++++++++ .../xWorksTests/WinFormsUiaSmokeTests.cs | 386 ++++++++++++++++++ Src/xWorks/xWorksTests/xWorksTests.csproj | 2 + build.ps1 | 59 ++- .../avalonia-migration-roadmap/design.md | 154 +++++-- .../avalonia-migration-roadmap/proposal.md | 49 ++- .../hybrid-alignment.md | 36 +- .../proposal.md | 12 + .../architecture-diagrams.md | 85 +++- .../coverage-map.md | 38 +- .../lexical-edit-avalonia-migration/design.md | 49 ++- .../phase2-execution-evidence.md | 21 +- .../proposal.md | 6 +- .../region-manifest.md | 18 +- .../seam-domain-comparison.md | 69 ++++ .../seam-recommendations.md | 59 ++- .../lexical-edit-avalonia-migration/spec.md | 32 ++ .../lexical-edit-parity-automation/spec.md | 12 + .../lexical-edit-avalonia-migration/tasks.md | 42 +- .../wiring-review-checklist.md | 50 +++ .../spike-evidence.md | 20 +- 56 files changed, 3382 insertions(+), 305 deletions(-) create mode 100644 .github/skills/fieldworks-localization-review/SKILL.md create mode 100644 .github/skills/fieldworks-ui-wiring-review/SKILL.md create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionModelTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/SurfaceAndHostContractTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionMetadataTests.cs create mode 100644 Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs create mode 100644 Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs create mode 100644 Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs create mode 100644 Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs create mode 100644 Src/Common/FwAvalonia/Seams/ActiveHostContract.cs create mode 100644 Src/Common/FwAvalonia/Seams/IHostSurface.cs create mode 100644 Src/LexText/LexTextControls/LexTextControlsTests/LexOptionsDlgTests.cs create mode 100644 Src/xWorks/LexicalEditRegionBuilder.cs create mode 100644 Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs create mode 100644 Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs create mode 100644 Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs create mode 100644 openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/wiring-review-checklist.md diff --git a/.github/skills/fieldworks-avalonia-ui/SKILL.md b/.github/skills/fieldworks-avalonia-ui/SKILL.md index a83a15bc89..a140684e84 100644 --- a/.github/skills/fieldworks-avalonia-ui/SKILL.md +++ b/.github/skills/fieldworks-avalonia-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: fieldworks-avalonia-ui -description: Use when creating, reviewing, or fixing Avalonia UI modules in FieldWorks, especially XAML, MVVM, preview-host, localization, accessibility, or net8 Avalonia test changes. +description: Use when creating, reviewing, or fixing Avalonia UI modules in FieldWorks, especially XAML, MVVM, preview-host, localization, accessibility, product-vs-preview wiring, or net48/net8 Avalonia test changes. --- # FieldWorks Avalonia UI @@ -9,6 +9,7 @@ description: Use when creating, reviewing, or fixing Avalonia UI modules in Fiel - Avalonia XAML, view models, commands, lifetimes, dispatching, and resource/style changes. - New or changed projects under `Src/**/**/*.Avalonia/`, `Src/Common/FwAvalonia/`, and `Src/Common/FwAvaloniaPreviewHost/`. - Preview Host module registration, sample data providers, and UI diagnostics. +- Global or per-screen UI host wiring that selects between Avalonia and legacy UI, including app-setting, `PropertyTable`, mediator, and product-vs-preview routing changes. ## Required Checks - Use current Avalonia docs for uncertain APIs; do not guess dispatcher, headless, automation, or binding behavior. @@ -16,15 +17,22 @@ description: Use when creating, reviewing, or fixing Avalonia UI modules in Fiel - Stable accessibility identity belongs on user-facing controls via Avalonia automation properties. - UI work should stay in bindings/view models where practical; avoid logic-heavy code-behind. - Keep module preview data lightweight unless the change explicitly opts into LCModel/project data. -- Preserve repo build/test entry points: `./build.ps1` and `./test.ps1`. +- If a change touches a UI mode or host switch, trace the full wiring path: setting source, `PropertyTable`/mediator broadcast, listener registration, host reload path, focus/command routing, and explicit fallback state for every affected consumer. +- Global runtime switches are product behavior. Audit every affected host/consumer, not only the first lexical surface. +- Product-facing Avalonia paths must use real edit-session/domain contracts; detached DTO-only models remain preview-only. +- Preserve repo build/test entry points: `./build.ps1` and `./test.ps1`, and make sure Avalonia projects/tests are covered through the normal repo graph rather than only through optional branch-specific lanes. - For Path 3 visual parity, remember the official Avalonia behavior: headless tests can simulate keyboard/mouse/text input on `Window`, `Dispatcher.UIThread.RunJobs()` flushes deferred UI work, and visual regression capture requires Skia + `UseHeadlessDrawing=false` with `CaptureRenderedFrame()`. - Stamp stable `AutomationProperties.Name` and `AutomationProperties.AutomationId` on user-facing controls that participate in parity bundles so the UIA/accessibility lane can identify them reliably. ## Review Red Flags - A Common project directly references a feature module without an explicit architecture decision. - Preview-only code is launched from product UI without a feature gate and real-project behavior story. +- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar handlers instead of proving the real runtime broadcast/wiring path. +- The active Avalonia path still initializes or drives hidden legacy rendering/menu infrastructure without an explicit approved baseline-only reason. +- A product-facing route uses preview host code, preview DTOs, or a lossy mapper as if it were a migrated surface. +- Optional or branch-specific Avalonia build/test lanes are treated as the only integration evidence. - Sleep-based or timing-sensitive UI tests. - Claims of accessibility, localization, IME, or keyboard parity without executable evidence. ## Handoff -Report exact Avalonia docs consulted, tests run, remaining prototype gaps, and whether the change is product-facing or preview-only. For Path 3 work, say whether the visual evidence is control-level headless capture or live desktop capture, and which accessibility identities were assigned via `AutomationProperties`. \ No newline at end of file +Report exact Avalonia docs consulted, tests run, remaining prototype gaps, whether the change is product-facing or preview-only, and how the live wiring path was validated for each affected host. For Path 3 work, say whether the visual evidence is control-level headless capture or live desktop capture, and which accessibility identities were assigned via `AutomationProperties`. \ No newline at end of file diff --git a/.github/skills/fieldworks-localization-review/SKILL.md b/.github/skills/fieldworks-localization-review/SKILL.md new file mode 100644 index 0000000000..e9d3b04684 --- /dev/null +++ b/.github/skills/fieldworks-localization-review/SKILL.md @@ -0,0 +1,27 @@ +--- +name: fieldworks-localization-review +description: Use when reviewing or changing FieldWorks user-facing strings, `.resx` resources, localization keys, Crowdin-facing assets, or localization-sensitive automation metadata. +--- + +# FieldWorks Localization Review + +## Use This For +- Product-facing text in WinForms, Avalonia, settings UI, dialogs, validation messages, fallback or unsupported-surface text, and promoted preview paths. +- `.resx` additions or changes, localization key flow, and Crowdin-sensitive resource updates. +- Automation metadata where `Name`, tooltip, or label is localized but stable `AutomationId` must remain nonlocalized. + +## Required Checks +- Product-facing user-visible strings live in `.resx` or the established localization mechanism; preview-only hardcoded text must stay clearly preview-only. +- New UI mode labels, fallback or unsupported messages, validation errors, and diagnostics are localized before a product path is exposed. +- Stable `AutomationId` and other selectors remain nonlocalized; localized names, tooltips, and labels may vary by locale. +- Resource keys and files align with existing Crowdin and repo localization conventions. +- If localization parity is claimed, tests or evidence cover the localized path and confirm selectors do not depend on localized text. + +## Review Red Flags +- Hardcoded English text in product C#, XAML, or product-facing preview-promotion paths. +- Tests or automation selectors depend on localized labels when stable IDs exist or are required. +- A product route reuses preview-only placeholder text. +- Localization claims are made without resource updates or without identifying remaining hardcoded strings. + +## Handoff +List the resource files or keys touched, remaining hardcoded product strings, automation identity strategy, and whether localized behavior has executable evidence or is still pending. \ No newline at end of file diff --git a/.github/skills/fieldworks-migration-scope-review/SKILL.md b/.github/skills/fieldworks-migration-scope-review/SKILL.md index 2c18dafefc..a813eb99c0 100644 --- a/.github/skills/fieldworks-migration-scope-review/SKILL.md +++ b/.github/skills/fieldworks-migration-scope-review/SKILL.md @@ -9,21 +9,25 @@ description: Use when reviewing large FieldWorks migration PRs, OpenSpec changes Treat foundational migration PRs as architecture and evidence packages. The main question is whether reviewers can trust the scope, claims, and validation boundary. ## Required Checks +- Scope review is branch-relative: compare `main..HEAD` or the merge-base diff, not calendar-time commit lists. Same-day commits already on `main` are not branch scope. - Compare PR title/body/tasks against the actual diff. - Classify files as plan/spec, characterization test, infrastructure, prototype, product behavior, or unrelated change. +- When product or global UI wiring appears, trace preview-vs-product routing and host/listener wiring separately from plan/test changes. - Verify checked tasks match evidence language; downgrade claims when evidence says substitute, placeholder, skipped, future, or partial. -- Confirm validation gates are explicit: OpenSpec validation, targeted tests, `./build.ps1`, and `CI: Full local check` when ready. +- Confirm validation gates are explicit: OpenSpec validation, targeted tests, normal `./build.ps1` and `./test.ps1` coverage for Avalonia, and `CI: Full local check` when ready. ## Split Triggers - Product-visible behavior appears in a planning/test PR. +- Branch-only diff mixes product-visible wiring with planning/test/docs/prototype work. - Common infrastructure directly depends on the first feature module without an explicit decision. - Test-runner/build graph changes are mixed with UI migration work. - Unrelated behavior changes require their own review context. ## Review Red Flags - A draft PR is so broad that each reviewer must reverse-engineer intent. +- Scope complaints are based on "commits made today" instead of the branch-only diff against `main`. - Evidence is stale after rebase or differs from visible CI state. - A prototype is wired as if it were a product feature. ## Handoff -Lead with blockers, then list what to remove, split, reword, or validate before review. \ No newline at end of file +Lead with blockers, then list what to remove, split, reword, or validate before review. Call out false scope signals separately from real branch-only scope problems. \ No newline at end of file diff --git a/.github/skills/fieldworks-semantic-render-parity/SKILL.md b/.github/skills/fieldworks-semantic-render-parity/SKILL.md index cba4c2b18f..b659a18b0b 100644 --- a/.github/skills/fieldworks-semantic-render-parity/SKILL.md +++ b/.github/skills/fieldworks-semantic-render-parity/SKILL.md @@ -10,6 +10,7 @@ Semantic snapshots should preserve behaviorally meaningful identity and omit inc ## Include - Stable node ID and source layout/part identity. +- When a scenario can run through multiple hosts or fallback states, record which route produced the artifact (`Avalonia`, legacy fallback, or blocked state). - Object/class binding, field/flid binding, editor kind, writing-system metadata, visibility, ghost state, expansion, focus order, localization key, and accessibility identity. - Unsupported construct diagnostics with enough path context to fix the source layout. @@ -34,6 +35,7 @@ Use the semantic snapshot as the anchor. Visual variance should be interpreted a Control-level Avalonia visual evidence may come from Avalonia.Headless rendered frames when the scenario is explicitly control-scoped. Desktop workflow/accessibility claims still need live-window evidence. ## Review Red Flags +- A preview-only or lossy route is presented as if it proved product parity. - Placeholder metadata is presented as real binding or writing-system parity. - Snapshot tests update large JSON blobs without a small behavioral explanation. - Cache invalidation tests depend on sleeps or filesystem timestamp luck. diff --git a/.github/skills/fieldworks-ui-wiring-review/SKILL.md b/.github/skills/fieldworks-ui-wiring-review/SKILL.md new file mode 100644 index 0000000000..c655e5cf7a --- /dev/null +++ b/.github/skills/fieldworks-ui-wiring-review/SKILL.md @@ -0,0 +1,29 @@ +--- +name: fieldworks-ui-wiring-review +description: Use when reviewing or changing FieldWorks UI wiring: app-setting or `PropertyTable` routing, mediator notifications, current-content switching, host replacement, preview-vs-product boundaries, or global legacy-vs-Avalonia UI selection. +--- + +# FieldWorks UI Wiring Review + +## Use This For +- Global or screen-level UI mode selection. +- `PropertyTable`, app-setting, mediator, or listener changes that affect which UI host is active. +- `RecordEditView`, `currentContentControl`, host replacement, save or `PrepareToGoAway()` routing, focus or command target routing, and preview-to-product promotion work. + +## Required Checks +- Review scope against the branch-only diff (`main..HEAD`) and list every host or consumer affected. +- Trace the full wiring path end to end: setting source, persisted state, `PropertyTable` key, mediator or property broadcast, listener registration, host reload path, focus or command target routing, save or `PrepareToGoAway()` path, and fallback or blocked state. +- For global switches, verify each current consumer has an explicit contract: supported Avalonia surface, explicit legacy fallback, or resource-backed unsupported state. +- The active Avalonia route must not instantiate or drive hidden legacy rendering or menu infrastructure except through explicitly approved baseline-only adapters. +- Product wiring and preview wiring must be reviewed separately; preview DTOs, preview hosts, and spike-only semantics do not satisfy product routing. +- Validation must use the normal repo build and test path (`./build.ps1`, `./test.ps1`) plus host-specific tests when wiring changes. + +## Review Red Flags +- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar handlers instead of driving the real setting and broadcast path. +- A preview-only mapper or detached DTO model sits on a product-facing route. +- Hidden legacy `DataTree`, menu handler, or renderer is still initialized and driven while Avalonia is the active host. +- A global setting changes unrelated screens without a manifest or explicit fallback story. +- Build or test evidence relies mainly on branch-only optional lanes or ad hoc commands. + +## Handoff +Report the setting source, listeners, affected hosts, per-host fallback state, executable proof of the live wiring path, and any remaining hidden legacy dependencies. \ No newline at end of file diff --git a/.github/skills/fieldworks-uia2-parity-testing/SKILL.md b/.github/skills/fieldworks-uia2-parity-testing/SKILL.md index b8bd63e527..9e0f6795c9 100644 --- a/.github/skills/fieldworks-uia2-parity-testing/SKILL.md +++ b/.github/skills/fieldworks-uia2-parity-testing/SKILL.md @@ -24,13 +24,15 @@ It does not replace semantic snapshots or visual/render evidence. A desktop auto ## Required Evidence - Stable automation IDs or accessible names for controls under test. - Explicit coverage of focus movement, invoke/click path, popup/chooser reachability, keyboard shortcuts, and failure artifacts. +- When UI mode or host wiring changes, desktop automation must cover the real switch-driven host refresh or fallback behavior on realized windows; manual handler calls or headless-only assertions do not prove product wiring. - Clear CI lane: headless can run broadly; desktop automation needs an interactive Windows desktop or a configured automation host. ## Review Red Flags - “Runs in the background” used for UIA2/Appium without explaining the required desktop/session. +- Manual `OnPropertyChanged(...)` or similar handler invocation is presented as proof of live UI-mode wiring. - Tests assert implementation internals instead of user-observable accessibility behavior. - Automation selectors rely on localized labels when stable IDs are available or required. - IME coverage is claimed without a real text editor/control surface and input-method evidence. ## Handoff -Classify each test as headless, native desktop automation, or smoke substitute, and state what parity claim it can and cannot support. When used in a Path 3 bundle, say explicitly which workflow/accessibility assertions the desktop lane proved and which still need another lane. \ No newline at end of file +Classify each test as headless, native desktop automation, or smoke substitute, and state what parity claim it can and cannot support. When used in a Path 3 bundle, say explicitly which workflow/accessibility assertions the desktop lane proved, whether switch wiring/fallback was exercised on a realized window, and which claims still need another lane. \ No newline at end of file diff --git a/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md b/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md index 3b03dd4c30..2a11eebf0e 100644 --- a/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md +++ b/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md @@ -8,8 +8,15 @@ description: Use when planning, reviewing, or implementing FieldWorks WinForms/x ## Core Rule Migrate by proving behavior first, extracting seams second, and introducing Avalonia controls only after legacy behavior has executable parity evidence. +## Workflow +1. Prove current behavior, including global UI wiring and fallback behavior. +2. Extract clean seams and explicit host contracts before exposing new product wiring. +3. Promote Avalonia from preview to product only after persistence, localization, and parity evidence exists. +4. Keep WinForms fallback explicit until the migrated region manifest says otherwise. + ## Required Baselines - Entry points: `RecordEditView`, `DataTree`, `SliceFactory`, XMLViews browse/table views, launchers, popup choosers, and command/listener wiring. +- Global wiring: app-setting source, `PropertyTable`/mediator broadcast, live host refresh, focus/command routing, and explicit fallback or blocked state for every affected host. - Semantics: object/class binding, flid/field binding, labels, visibility, ghost state, expansion, focus order, writing-system metadata, accessibility identity, and localization keys. - User workflows: create/edit/save/cancel, chooser OK/cancel, undo/redo, refresh/postponed `PropChanged`, keyboard focus restoration, and disposal/unsubscribe. @@ -17,12 +24,17 @@ Migrate by proving behavior first, extracting seams second, and introducing Aval - Keep WinForms Designer-safe code isolated from extracted logic. - Extract humble objects/services for modal decisions and data-loss classifiers before replacing controls. - Put an editor registry or adapter boundary in front of legacy `SliceFactory` behavior before mixing legacy and Avalonia editors. +- Keep the global UI mode contract explicit: the switch may be app-wide, but each consumer must have a deliberate supported, fallback, or blocked state. +- Do not let the active Avalonia host instantiate or drive hidden legacy `DataTree` or menu infrastructure except through explicitly approved baseline adapters. - Treat product command wiring as product behavior, not preview scaffolding. ## Review Red Flags - A PR mixes plans, tests, infrastructure, product UI wiring, and unrelated behavior changes. +- Tests manually invoke `OnPropertyChanged`, `ShowRecord`, or similar handlers to simulate runtime wiring. +- Active Avalonia routing depends on a lossy POC DTO mapper or partial `LexEntry`-only fallback without an explicit product contract. +- Avalonia integration is validated only through `-BuildAvalonia` or ad hoc commands instead of the normal repo build/test path. - Task checkboxes claim UIA2/IME/accessibility/localization parity while evidence says substitute, placeholder, skipped, or future work. - Avalonia preview data modifies or pretends to modify real project data without a real edit-session contract. ## Handoff -State what is legacy baseline, what is extracted seam, what is Avalonia prototype, and what remains outside parity. \ No newline at end of file +State what is legacy baseline, what is extracted seam, what is Avalonia prototype, what each affected host does under the global switch, and what remains outside parity. \ No newline at end of file diff --git a/Build/RegFree.targets b/Build/RegFree.targets index e83d420156..d2962ac57a 100644 --- a/Build/RegFree.targets +++ b/Build/RegFree.targets @@ -66,7 +66,6 @@ -
diff --git a/Directory.Packages.props b/Directory.Packages.props index 8147d0fd71..486223338c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -160,7 +160,8 @@ diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs index 0f634929a0..4c3f3a2629 100644 --- a/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/MorphTypeAtomicLauncherTests.cs @@ -185,6 +185,39 @@ public void DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh() Assert.That(m_dtree.RefreshListNeeded, Is.False); } + [Test] + public void DoNotRefresh_RemainingSliceRestoresFocus_AfterRefreshRebuild() + { + m_dtree.Initialize(Cache, false, m_layouts, m_parts); + m_parent.Show(); + m_dtree.ShowObject(m_entry, "CfAndBib", null, m_entry, false); + Application.DoEvents(); + + var citationSlice = (Slice)m_dtree.Controls[0]; + citationSlice.Control.Focus(); + Application.DoEvents(); + + Assert.That( + citationSlice.Control.ContainsFocus || citationSlice.Control.Focused, + Is.True, + "Setup: CitationForm should hold focus before the refresh-triggering change."); + + m_dtree.DoNotRefresh = true; + m_entry.Bibliography.SetVernacularDefaultWritingSystem(string.Empty); + m_entry.Bibliography.SetAnalysisDefaultWritingSystem(string.Empty); + m_dtree.RefreshListNeeded = true; + m_dtree.DoNotRefresh = false; + Application.DoEvents(); + + Assert.That(m_dtree.Controls.Count, Is.EqualTo(1)); + var remainingSlice = (Slice)m_dtree.Controls[0]; + Assert.That(remainingSlice.Label, Is.EqualTo("CitationForm")); + Assert.That( + remainingSlice.Control.ContainsFocus || remainingSlice.Control.Focused, + Is.True, + "Focus should restore to the remaining visible slice after the refresh rebuild."); + } + [TestCaseSource(nameof(StemLikeMorphTypes))] public void IsStemType_StemLikeMorphTypes_ReturnsTrue(Guid morphTypeGuid) { diff --git a/Src/Common/Controls/XMLViews/FilterBar.cs b/Src/Common/Controls/XMLViews/FilterBar.cs index 8113ea7276..422aaf7c75 100644 --- a/Src/Common/Controls/XMLViews/FilterBar.cs +++ b/Src/Common/Controls/XMLViews/FilterBar.cs @@ -1072,10 +1072,65 @@ protected void MakeCombo(FilterSortItem item) combo.SelectedIndex = 0; // Do this after selecting initial item, so we don't get a spurious notification. combo.SelectedIndexChanged += Combo_SelectedIndexChanged; - combo.AccessibleName = "FwComboBox"; + combo.Name = GetFilterComboAutomationId(item.Spec); + combo.AccessibleName = GetFilterComboAccessibleName(item.Spec); Controls.Add(combo); } + private static string GetFilterComboAutomationId(XmlNode spec) + { + return "FilterCombo." + SanitizeForAutomationId(GetFilterComboStableKey(spec)); + } + + private static string GetFilterComboAccessibleName(XmlNode spec) + { + var header = XmlUtils.GetOptionalAttributeValue(spec, "headerlabel", null); + if (!string.IsNullOrEmpty(header)) + return header; + + var label = XmlUtils.GetOptionalAttributeValue(spec, "label", null); + if (!string.IsNullOrEmpty(label)) + return label; + + return GetFilterComboStableKey(spec); + } + + private static string GetFilterComboStableKey(XmlNode spec) + { + var layout = XmlUtils.GetOptionalAttributeValue(spec, "layout", null); + if (!string.IsNullOrEmpty(layout)) + return layout; + + var field = XmlUtils.GetOptionalAttributeValue(spec, "field", null); + var subfield = XmlUtils.GetOptionalAttributeValue(spec, "subfield", null); + if (!string.IsNullOrEmpty(field) && !string.IsNullOrEmpty(subfield)) + return field + "." + subfield; + if (!string.IsNullOrEmpty(field)) + return field; + + var listId = XmlUtils.GetOptionalAttributeValue(spec, "list", null); + if (!string.IsNullOrEmpty(listId)) + return listId; + + var header = XmlUtils.GetOptionalAttributeValue(spec, "headerlabel", null); + if (!string.IsNullOrEmpty(header)) + return header; + + var label = XmlUtils.GetOptionalAttributeValue(spec, "label", null); + if (!string.IsNullOrEmpty(label)) + return label; + + return "Column"; + } + + private static string SanitizeForAutomationId(string value) + { + if (string.IsNullOrEmpty(value)) + return "Column"; + + return new string(value.Select(c => char.IsLetterOrDigit(c) || c == '_' || c == '.' ? c : '_').ToArray()); + } + private void AddSpellingErrorsIfAppropriate(FilterSortItem item, FwComboBox combo, int ws) { // LT-9047 For certain fields, filtering on Spelling Errors just doesn't make sense. @@ -1225,7 +1280,8 @@ protected void MakeIntCombo(FilterSortItem item) combo.SelectedIndex = 0; // Do this after selecting initial item, so we don't get a spurious notification. combo.SelectedIndexChanged += Combo_SelectedIndexChanged; - combo.AccessibleName = "FwComboBox"; + combo.Name = GetFilterComboAutomationId(item.Spec); + combo.AccessibleName = GetFilterComboAccessibleName(item.Spec); Controls.Add(combo); } diff --git a/Src/Common/FieldWorks/BuildInclude.targets b/Src/Common/FieldWorks/BuildInclude.targets index 47f39f2eee..600d43a2e2 100644 --- a/Src/Common/FieldWorks/BuildInclude.targets +++ b/Src/Common/FieldWorks/BuildInclude.targets @@ -7,7 +7,6 @@ - diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs index 50f54e1df4..9121c6f89a 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditSurfaceResolverTests.cs @@ -21,47 +21,34 @@ public class LexicalEditSurfaceResolverTests [Test] public void Resolve_DefaultsToWinForms_WhenFlagUnset() { - var surface = LexicalEditSurfaceResolver.Resolve(envReader: _ => null); - Assert.That(surface, Is.EqualTo(LexicalEditSurface.WinForms)); - } - - [TestCase("1")] - [TestCase("true")] - [TestCase("TRUE")] - [TestCase("on")] - [TestCase("yes")] - public void Resolve_SelectsAvalonia_WhenFlagTruthy(string value) - { - var surface = LexicalEditSurfaceResolver.Resolve( - envReader: name => name == LexicalEditSurfaceResolver.FlagEnvVar ? value : null); - Assert.That(surface, Is.EqualTo(LexicalEditSurface.Avalonia)); - } - - [TestCase("")] - [TestCase("0")] - [TestCase("false")] - [TestCase("off")] - [TestCase("nonsense")] - public void Resolve_StaysWinForms_WhenFlagFalsy(string value) - { - var surface = LexicalEditSurfaceResolver.Resolve( - envReader: name => name == LexicalEditSurfaceResolver.FlagEnvVar ? value : null); + var surface = LexicalEditSurfaceResolver.Resolve(); Assert.That(surface, Is.EqualTo(LexicalEditSurface.WinForms)); } [Test] - public void Resolve_OverrideWinsOverEnvironment() + public void Resolve_OverrideWinsOverPersistedUIMode() { - // Environment says "on", but the explicit override says off -> WinForms. var winForms = LexicalEditSurfaceResolver.Resolve( - envReader: _ => "1", overrideEnabled: false); + overrideEnabled: false, + uiMode: LexicalEditSurfaceResolver.NewUIMode); Assert.That(winForms, Is.EqualTo(LexicalEditSurface.WinForms)); - // Environment unset, but override says on -> Avalonia. var avalonia = LexicalEditSurfaceResolver.Resolve( - envReader: _ => null, overrideEnabled: true); + overrideEnabled: true, + uiMode: LexicalEditSurfaceResolver.LegacyUIMode); Assert.That(avalonia, Is.EqualTo(LexicalEditSurface.Avalonia)); } + + [TestCase(LexicalEditSurfaceResolver.LegacyUIMode, LexicalEditSurface.WinForms)] + [TestCase(LexicalEditSurfaceResolver.NewUIMode, LexicalEditSurface.Avalonia)] + [TestCase(null, LexicalEditSurface.WinForms)] + [TestCase("", LexicalEditSurface.WinForms)] + [TestCase("SomethingElse", LexicalEditSurface.WinForms)] + public void Resolve_UsesPersistedUIMode(string uiMode, LexicalEditSurface expected) + { + var surface = LexicalEditSurfaceResolver.Resolve(uiMode: uiMode); + Assert.That(surface, Is.EqualTo(expected)); + } } /// Tests that the factory never constructs the Avalonia surface when the flag is off. diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs index 97fb02a02a..cdb7219a59 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; +using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Headless.NUnit; using Avalonia.Threading; @@ -93,6 +94,35 @@ public void PocSlice_MorphTypeChooser_UpdatesEntryAndReturnsFocus() Assert.That(slice.MorphTypeChooser.Button.IsFocused, Is.True, "focus returns to the host button after choosing"); } + [AvaloniaTest] + public void PocSlice_FocusedTextBox_UnicodeEditUpdatesEntry_AndKeepsFocus() + { + var (slice, _) = ShowSlice(); + var box = slice.LexemeFormEditor.Boxes[0]; + + box.Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(box.IsFocused, Is.True, "text box should accept focus before the edit"); + + box.Text = "ka\u0301 東京"; + Dispatcher.UIThread.RunJobs(); + + Assert.That(slice.Entry.LexemeForm[0].Value, Is.EqualTo("ka\u0301 東京")); + Assert.That(box.IsFocused, Is.True, "focused text entry should keep focus after the edit write-through"); + } + + [AvaloniaTest] + public void PocSlice_UserFacingControls_ExposeStableAutomationMetadata() + { + var (slice, _) = ShowSlice(); + + Assert.That(AutomationProperties.GetAutomationId(slice), Is.EqualTo("PocLexEntrySlice")); + Assert.That(AutomationProperties.GetAutomationId(slice.LexemeFormEditor), Is.EqualTo("LexemeFormEditor")); + Assert.That(AutomationProperties.GetAutomationId(slice.LexemeFormEditor.Boxes[0]), Is.EqualTo("LexemeFormEditor.seh")); + Assert.That(AutomationProperties.GetAutomationId(slice.SenseGlossEditor), Is.EqualTo("SenseGlossEditor")); + Assert.That(AutomationProperties.GetAutomationId(slice.MorphTypeChooser.Button), Is.EqualTo("MorphTypeChooser.Button")); + } + [AvaloniaTest] public void PocSlice_SemanticSnapshot_IsDeterministic() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionModelTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionModelTests.cs new file mode 100644 index 0000000000..1800662a9b --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionModelTests.cs @@ -0,0 +1,170 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// A fake value provider so the mapper can be tested without LCModel. The LCModel-backed provider + /// lives in xWorks (LexicalEditRegionBuilder). + /// + internal sealed class FakeRegionValueProvider : IRegionValueProvider + { + public IReadOnlyList GetValues(ViewNode fieldNode) + { + switch (fieldNode.Field) + { + case "LexemeForm": + return new List { new RegionWsValue("vern", "dog", "Charis SIL", 12) }; + case "Gloss": + return new List { new RegionWsValue("anal", "canine") }; + default: + return new List(); + } + } + + public IReadOnlyList GetOptions(ViewNode fieldNode) + => new List { new RegionChoiceOption("stem", "stem"), new RegionChoiceOption("suffix", "suffix") }; + + public string GetSelectedOptionKey(ViewNode fieldNode) => "suffix"; + } + + [TestFixture] + public class LexicalEditRegionMapperTests + { + private static ViewDefinitionModel SampleDefinition() + { + var roots = new List + { + new ViewNode("LexEntry/identity/#0", ViewNodeKind.Field, "Lexeme Form", null, "LexemeForm", "multistring", + EditorClassification.Known, "vernacular", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "LexemeFormEditor", routing: SurfaceRouting.Product), + new ViewNode("LexEntry/identity/#1", ViewNodeKind.Field, "Morph Type", null, "MorphType", "morphtypeatomicreference", + EditorClassification.Known, null, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "MorphTypeChooser", routing: SurfaceRouting.Product), + new ViewNode("LexEntry/identity/#2", ViewNodeKind.Field, "Gloss", null, "Gloss", "multistring", + EditorClassification.Known, "analysis", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "SenseGlossEditor", routing: SurfaceRouting.Product) + }; + return new ViewDefinitionModel("LexEntry", "identity", "detail", roots, new List()); + } + + [Test] + public void FromViewDefinition_ProjectsFields_FromTheTypedDefinition() + { + var model = LexicalEditRegionMapper.FromViewDefinition(SampleDefinition(), new FakeRegionValueProvider()); + + Assert.That(model.ClassName, Is.EqualTo("LexEntry")); + Assert.That(model.Fields.Select(f => f.Field), Is.EqualTo(new[] { "LexemeForm", "MorphType", "Gloss" })); + } + + [Test] + public void TextFields_AreClassifiedAsText_AndBoundToValues() + { + var model = LexicalEditRegionMapper.FromViewDefinition(SampleDefinition(), new FakeRegionValueProvider()); + var lexeme = model.Fields.Single(f => f.Field == "LexemeForm"); + + Assert.That(lexeme.Kind, Is.EqualTo(RegionFieldKind.Text)); + Assert.That(lexeme.Values.Single().Value, Is.EqualTo("dog")); + Assert.That(lexeme.AutomationId, Is.EqualTo("LexemeFormEditor")); + } + + [Test] + public void ChooserField_IsClassifiedAsChooser_WithOptionsAndSelection() + { + var model = LexicalEditRegionMapper.FromViewDefinition(SampleDefinition(), new FakeRegionValueProvider()); + var morph = model.Fields.Single(f => f.Field == "MorphType"); + + Assert.That(morph.Kind, Is.EqualTo(RegionFieldKind.Chooser)); + Assert.That(morph.Options.Select(o => o.Key), Is.EqualTo(new[] { "stem", "suffix" })); + Assert.That(morph.SelectedOptionKey, Is.EqualTo("suffix")); + } + + [Test] + public void NeverVisibleFields_AreExcluded() + { + var roots = new List + { + new ViewNode("x/#0", ViewNodeKind.Field, "Hidden", null, "Hidden", "multistring", + EditorClassification.Known, null, ViewVisibility.Never, ViewExpansion.NotApplicable, false, null, null) + }; + var def = new ViewDefinitionModel("LexEntry", "identity", "detail", roots, new List()); + + var model = LexicalEditRegionMapper.FromViewDefinition(def, new FakeRegionValueProvider()); + Assert.That(model.Fields, Is.Empty); + } + + [Test] + public void ObsoleteEditor_IsClassifiedUnsupported() + { + var roots = new List + { + new ViewNode("x/#0", ViewNodeKind.Field, "Old", null, "Old", "message", + EditorClassification.Obsolete, null, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null) + }; + var def = new ViewDefinitionModel("LexEntry", "identity", "detail", roots, new List()); + + var model = LexicalEditRegionMapper.FromViewDefinition(def, new FakeRegionValueProvider()); + Assert.That(model.Fields.Single().Kind, Is.EqualTo(RegionFieldKind.Unsupported)); + } + + [Test] + public void Diagnostics_ArePreserved_FromTheDefinition() + { + var diags = new List { new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "x", "m", "p") }; + var def = new ViewDefinitionModel("LexEntry", "identity", "detail", new List(), diags); + + var model = LexicalEditRegionMapper.FromViewDefinition(def, new FakeRegionValueProvider()); + Assert.That(model.Diagnostics, Has.Count.EqualTo(1)); + } + } + + [TestFixture] + public class LexicalEditRegionViewTests + { + private static ViewDefinitionModel SampleDefinition() => new ViewDefinitionModel( + "LexEntry", "identity", "detail", + new List + { + new ViewNode("LexEntry/identity/#0", ViewNodeKind.Field, "Lexeme Form", null, "LexemeForm", "multistring", + EditorClassification.Known, "vernacular", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "LexemeFormEditor", routing: SurfaceRouting.Product), + new ViewNode("LexEntry/identity/#1", ViewNodeKind.Field, "Morph Type", null, "MorphType", "morphtypeatomicreference", + EditorClassification.Known, null, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "MorphTypeChooser", routing: SurfaceRouting.Product) + }, + new List()); + + [AvaloniaTest] + public void RegionView_RendersFields_WithStableAutomationIds() + { + var model = LexicalEditRegionMapper.FromViewDefinition(SampleDefinition(), new FakeRegionValueProvider()); + var view = new LexicalEditRegionView(model); + var window = new Window { Content = view, Width = 420, Height = 240 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(AutomationProperties.GetAutomationId(view), Is.EqualTo("LexicalEditRegionView")); + + var lexemeBox = view.GetVisualDescendants().OfType() + .FirstOrDefault(b => AutomationProperties.GetAutomationId(b) == "LexemeFormEditor.vern"); + Assert.That(lexemeBox, Is.Not.Null, "the text field should render a per-ws box with a stable automation id"); + Assert.That(lexemeBox.Text, Is.EqualTo("dog")); + + var chooser = view.GetVisualDescendants().OfType() + .FirstOrDefault(c => AutomationProperties.GetAutomationId(c) == "MorphTypeChooser"); + Assert.That(chooser, Is.Not.Null, "the chooser field should render a combo box"); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/SurfaceAndHostContractTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/SurfaceAndHostContractTests.cs new file mode 100644 index 0000000000..040ca53feb --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/SurfaceAndHostContractTests.cs @@ -0,0 +1,140 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Seams; + +namespace FwAvaloniaTests +{ + [TestFixture] + public class LexicalEditSurfaceSelectionServiceTests + { + private readonly LexicalEditSurfaceSelectionService _service = new LexicalEditSurfaceSelectionService(); + + [Test] + public void NewMode_SupportedTool_IsSupportedAvalonia() + { + var decision = _service.Decide("New", "lexiconEdit"); + Assert.That(decision.Surface, Is.EqualTo(LexicalEditSurface.Avalonia)); + Assert.That(decision.Behavior, Is.EqualTo(HostUiBehavior.SupportedAvalonia)); + } + + [Test] + public void NewMode_UnmigratedTool_IsExplicitLegacyFallback() + { + var decision = _service.Decide("New", "posEdit"); + Assert.That(decision.Surface, Is.EqualTo(LexicalEditSurface.WinForms)); + Assert.That(decision.Behavior, Is.EqualTo(HostUiBehavior.ExplicitLegacyFallback)); + } + + [Test] + public void LegacyMode_SupportedTool_IsLegacyActive() + { + var decision = _service.Decide("Legacy", "lexiconEdit"); + Assert.That(decision.Surface, Is.EqualTo(LexicalEditSurface.WinForms)); + Assert.That(decision.Behavior, Is.EqualTo(HostUiBehavior.LegacyActive)); + } + + [Test] + public void Override_ForcesAvalonia_ForSupportedTool() + { + var decision = _service.Decide("Legacy", "lexiconEdit", overrideEnabled: true); + Assert.That(decision.Surface, Is.EqualTo(LexicalEditSurface.Avalonia)); + Assert.That(decision.Behavior, Is.EqualTo(HostUiBehavior.SupportedAvalonia)); + } + + [Test] + public void EveryDecision_HasAReason() + { + Assert.That(_service.Decide("New", "lexiconEdit").Reason, Is.Not.Empty); + Assert.That(_service.Decide("New", "posEdit").Reason, Is.Not.Empty); + Assert.That(_service.Decide("Legacy", "lexiconEdit").Reason, Is.Not.Empty); + } + } + + [TestFixture] + public class ActiveHostContractTests + { + [Test] + public void Legacy_PermitsLegacyDataTreeDrive() + { + var contract = ActiveHostContract.ForLegacy(); + Assert.That(contract.PermitsLegacyDataTreeDrive(), Is.True); + Assert.That(contract.PermitsLegacyDataTreeDrive("anything"), Is.True); + } + + [Test] + public void Avalonia_ForbidsLegacyDataTreeDrive_ByDefault() + { + var contract = ActiveHostContract.ForAvalonia(); + Assert.That(contract.PermitsLegacyDataTreeDrive(), Is.False); + Assert.That(contract.PermitsLegacyDataTreeDrive("baseline-compare"), Is.False); + Assert.That(() => contract.AssertLegacyDataTreeDriveAllowed(), Throws.InvalidOperationException); + } + + [Test] + public void Avalonia_PermitsLegacyDrive_OnlyForApprovedBaselineAdapter() + { + var contract = ActiveHostContract.ForAvalonia("baseline-compare"); + Assert.That(contract.PermitsLegacyDataTreeDrive("baseline-compare"), Is.True); + Assert.That(contract.PermitsLegacyDataTreeDrive("other"), Is.False); + Assert.That(contract.PermitsLegacyDataTreeDrive(), Is.False); + } + } + + /// Proves the task 3.5 host/surface contract is satisfiable by a fake host. + [TestFixture] + public class HostSurfaceContractTests + { + private sealed class FakeSurface : ILexicalEditSurface + { + public FakeSurface(LexicalEditSurfaceKind kind) { Kind = kind; } + public LexicalEditSurfaceKind Kind { get; } + public bool IsInitialized { get; private set; } + public object ShownRecord { get; private set; } + public bool Visible { get; private set; } + public void EnsureInitialized() => IsInitialized = true; + public void ShowRecord(object record) { EnsureInitialized(); ShownRecord = record; Visible = true; } + public void Hide() => Visible = false; + public bool TrySetFocus() => Visible; + public bool TryShowContextMenu(object context) => Visible; + public void PrepareToReplace() { } + } + + private sealed class FakeHost : ILexicalEditHost + { + private readonly FakeSurface _legacy = new FakeSurface(LexicalEditSurfaceKind.Legacy); + private readonly FakeSurface _avalonia = new FakeSurface(LexicalEditSurfaceKind.Avalonia); + public LexicalEditSurfaceKind ActiveSurface { get; private set; } = LexicalEditSurfaceKind.Legacy; + public System.Collections.Generic.IReadOnlyList Surfaces => new ILexicalEditSurface[] { _legacy, _avalonia }; + public FakeSurface Legacy => _legacy; + public FakeSurface Avalonia => _avalonia; + + public void ReplaceSurface(LexicalEditSurfaceKind kind, object record) + { + var active = kind == LexicalEditSurfaceKind.Avalonia ? _avalonia : _legacy; + var inactive = kind == LexicalEditSurfaceKind.Avalonia ? _legacy : _avalonia; + inactive.PrepareToReplace(); + inactive.Hide(); + active.ShowRecord(record); + ActiveSurface = kind; + } + } + + [Test] + public void ReplaceSurface_ActivatesOnlyTheChosenSurface() + { + var host = new FakeHost(); + host.ReplaceSurface(LexicalEditSurfaceKind.Avalonia, "entry-1"); + + Assert.That(host.ActiveSurface, Is.EqualTo(LexicalEditSurfaceKind.Avalonia)); + Assert.That(host.Avalonia.Visible, Is.True); + Assert.That(host.Avalonia.ShownRecord, Is.EqualTo("entry-1")); + Assert.That(host.Legacy.Visible, Is.False); + // The inactive (legacy) surface was never driven to show a record. + Assert.That(host.Legacy.ShownRecord, Is.Null); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionMetadataTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionMetadataTests.cs new file mode 100644 index 0000000000..8e731de757 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionMetadataTests.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Task 4.7: localization/accessibility/routing metadata on typed view-definition nodes. Legacy XML + /// carries none of these, so imported legacy layouts keep their exact semantic snapshot; authored or + /// region-spec sources may set them and they then appear in the snapshot. + /// + [TestFixture] + public class ViewDefinitionMetadataTests + { + private sealed class StubResolver : IPartResolver + { + private readonly XElement _content; + public StubResolver(XElement content) { _content = content; } + public XElement ResolvePart(string className, string layoutType, string refName) => _content; + public XElement ResolvePartByRef(string refName) => _content; + } + + private static ViewDefinitionModel Import(string contentXml) + { + var layout = new XElement("layout", + new XAttribute("class", "LexEntry"), + new XAttribute("name", "test"), + new XAttribute("type", "detail"), + new XElement("part", new XAttribute("ref", "Field"))); + var content = XElement.Parse(contentXml); + return new XmlLayoutImporter().Import(layout, new StubResolver(content)); + } + + [Test] + public void Defaults_AreNullAndInherit_WhenNotAuthored() + { + var model = Import(""); + var node = model.Roots.Single(); + + Assert.That(node.LocalizationKey, Is.Null); + Assert.That(node.AutomationId, Is.Null); + Assert.That(node.Routing, Is.EqualTo(SurfaceRouting.Inherit)); + } + + [Test] + public void Snapshot_IsUnchanged_WhenNoMetadataIsPresent() + { + var model = Import(""); + var snapshot = model.ToSnapshot(); + + Assert.That(snapshot, Does.Not.Contain("loc=")); + Assert.That(snapshot, Does.Not.Contain("autoId=")); + Assert.That(snapshot, Does.Not.Contain("routing=")); + } + + [Test] + public void Importer_ReadsMetadataAttributes_WhenPresent() + { + var model = Import( + ""); + var node = model.Roots.Single(); + + Assert.That(node.LocalizationKey, Is.EqualTo("ksCitationForm")); + Assert.That(node.AutomationId, Is.EqualTo("CitationFormEditor")); + Assert.That(node.Routing, Is.EqualTo(SurfaceRouting.Product)); + } + + [Test] + public void Snapshot_IncludesMetadata_WhenPresent() + { + var model = Import( + ""); + var snapshot = model.ToSnapshot(); + + Assert.That(snapshot, Does.Contain("autoId=CitationFormEditor")); + Assert.That(snapshot, Does.Contain("routing=Preview")); + } + + [Test] + public void LabelId_IsAcceptedAsLocalizationKeyFallback() + { + var model = Import(""); + Assert.That(model.Roots.Single().LocalizationKey, Is.EqualTo("ksCf")); + } + } +} diff --git a/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs b/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs index b6142cf424..42194841b6 100644 --- a/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs +++ b/Src/Common/FwAvalonia/LexicalEditSurfaceResolver.cs @@ -21,14 +21,18 @@ public enum LexicalEditSurface /// /// Pure-logic resolver for the two-adapter feature flag described in - /// lexical-edit-avalonia-poc-spike. Default is WinForms; Avalonia is selected only when - /// an explicit override or the environment variable opts in. + /// lexical-edit-avalonia-poc-spike. Default is WinForms; Avalonia is selected by a persisted + /// `UIMode = New` preference or by an explicit override used in tests. /// This type has no Avalonia dependency so it can be unit tested without a UI runtime. /// public static class LexicalEditSurfaceResolver { - /// Environment variable that enables the Avalonia POC surface. - public const string FlagEnvVar = "FW_AVALONIA_LEXEDIT"; + private static readonly string[] SupportedAvaloniaToolNames = + { + "lexiconEdit", + "lexiconEditPopup" + }; + /// Property/app-setting key storing the preferred lexical-edit UI mode. public const string UIModePropertyName = "UIMode"; public const string LegacyUIMode = "Legacy"; @@ -36,26 +40,23 @@ public static class LexicalEditSurfaceResolver /// /// Resolves the surface to use. Resolution order: an explicit - /// wins; otherwise a truthy environment variable still forces Avalonia for developer/testing - /// scenarios; otherwise the persisted user preference is used. + /// wins; otherwise the persisted user preference is used. /// - /// Optional environment reader (defaults to the process environment). /// Optional strong override (PropertyTable/registry). /// Persisted user preference (`Legacy` or `New`). public static LexicalEditSurface Resolve( - Func envReader = null, bool? overrideEnabled = null, - string uiMode = null) + string uiMode = null, + string currentToolName = null) { - if (overrideEnabled.HasValue) + if (!SupportsAvaloniaForTool(currentToolName)) { - return overrideEnabled.Value ? LexicalEditSurface.Avalonia : LexicalEditSurface.WinForms; + return LexicalEditSurface.WinForms; } - var read = envReader ?? Environment.GetEnvironmentVariable; - if (IsTruthy(read(FlagEnvVar))) + if (overrideEnabled.HasValue) { - return LexicalEditSurface.Avalonia; + return overrideEnabled.Value ? LexicalEditSurface.Avalonia : LexicalEditSurface.WinForms; } return string.Equals(uiMode, NewUIMode, StringComparison.OrdinalIgnoreCase) @@ -66,18 +67,18 @@ public static LexicalEditSurface Resolve( public static string ToUIModeValue(LexicalEditSurface surface) => surface == LexicalEditSurface.Avalonia ? NewUIMode : LegacyUIMode; - private static bool IsTruthy(string value) + public static bool SupportsAvaloniaForTool(string currentToolName) { - if (string.IsNullOrWhiteSpace(value)) + if (string.IsNullOrWhiteSpace(currentToolName)) + return true; + + foreach (var toolName in SupportedAvaloniaToolNames) { - return false; + if (string.Equals(toolName, currentToolName, StringComparison.OrdinalIgnoreCase)) + return true; } - var v = value.Trim(); - return v == "1" - || v.Equals("true", StringComparison.OrdinalIgnoreCase) - || v.Equals("on", StringComparison.OrdinalIgnoreCase) - || v.Equals("yes", StringComparison.OrdinalIgnoreCase); + return false; } } } diff --git a/Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs b/Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs new file mode 100644 index 0000000000..e4e6c6fed6 --- /dev/null +++ b/Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs @@ -0,0 +1,87 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// The deliberate product behavior of a host under the app-wide UI mode (task 3.9 / manifest + /// uiModeBehavior). Every host must resolve to one of these — there is no ambiguous + /// "best effort" routing. + /// + public enum HostUiBehavior + { + /// Legacy UI mode is selected; this host renders the legacy surface. + LegacyActive, + + /// New UI mode and this host has a migrated Avalonia surface. + SupportedAvalonia, + + /// New UI mode but this host is not migrated, so it explicitly falls back to legacy. + ExplicitLegacyFallback, + + /// New UI mode and this host is neither migrated nor has a legacy fallback (reserved). + Blocked + } + + /// The resolved routing decision for a host: the concrete surface plus why it was chosen. + public sealed class SurfaceDecision + { + public SurfaceDecision(LexicalEditSurface surface, HostUiBehavior behavior, string reason) + { + Surface = surface; + Behavior = behavior; + Reason = reason; + } + + /// The concrete surface to render. + public LexicalEditSurface Surface { get; } + + /// The deliberate behavior classification behind the surface choice. + public HostUiBehavior Behavior { get; } + + /// Human-readable reason (for diagnostics/manifest evidence, not for control flow). + public string Reason { get; } + } + + /// + /// Explicit, central mapping from the app-wide UI mode to per-host behavior (task 3.9). Hosts such as + /// RecordEditView consume this instead of inferring product routing ad hoc from settings and + /// PropertyTable state. Pure logic over , with no + /// Avalonia dependency, so it is unit-testable without a UI runtime. + /// + public sealed class LexicalEditSurfaceSelectionService + { + /// + /// Resolves the surface decision for a host from the persisted UI mode and the current tool. + /// + /// Persisted user preference (Legacy or New). + /// The current content-control/tool name. + /// Optional strong override (PropertyTable/registry). + public SurfaceDecision Decide(string uiMode, string toolName, bool? overrideEnabled = null) + { + var supportsAvalonia = LexicalEditSurfaceResolver.SupportsAvaloniaForTool(toolName); + var surface = LexicalEditSurfaceResolver.Resolve(overrideEnabled, uiMode, toolName); + + if (surface == LexicalEditSurface.Avalonia) + { + return new SurfaceDecision(LexicalEditSurface.Avalonia, HostUiBehavior.SupportedAvalonia, + $"Avalonia is supported for tool '{toolName}' and the UI mode selects it."); + } + + // Surface resolved to WinForms. Distinguish "legacy mode" from "new mode, tool not migrated". + var isNewMode = overrideEnabled == true + || (!overrideEnabled.HasValue && string.Equals(uiMode, LexicalEditSurfaceResolver.NewUIMode, + System.StringComparison.OrdinalIgnoreCase)); + + if (isNewMode && !supportsAvalonia) + { + return new SurfaceDecision(LexicalEditSurface.WinForms, HostUiBehavior.ExplicitLegacyFallback, + $"Tool '{toolName}' is not migrated; it explicitly falls back to legacy under the New UI mode."); + } + + return new SurfaceDecision(LexicalEditSurface.WinForms, HostUiBehavior.LegacyActive, + "Legacy UI mode is selected."); + } + } +} diff --git a/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs index 948854cb30..8ef1ce282a 100644 --- a/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs +++ b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs @@ -5,6 +5,7 @@ using System; using System.Windows.Forms; using Avalonia.Win32.Interoperability; +using SIL.FieldWorks.Common.FwAvalonia.Region; namespace SIL.FieldWorks.Common.FwAvalonia.Poc { @@ -40,7 +41,21 @@ public PocWinFormsHostControl() Clear(); } - /// Displays the given lexical-entry DTO in the Avalonia POC slice. + /// + /// Displays a typed-definition-backed region model in the Avalonia surface (task 4.8). This is the + /// product render path; it replaces , which renders the lossy preview DTO. + /// + public void ShowRegion(LexicalEditRegionModel region) + { + if (region == null) throw new ArgumentNullException(nameof(region)); + _host.Content = new LexicalEditRegionView(region); + Show(); + } + + /// + /// Displays the given lexical-entry DTO in the Avalonia POC slice. Preview/sample only: the + /// product route uses with a typed-definition-backed region model. + /// public void ShowEntry(PocEntryDto entry) { if (entry == null) throw new ArgumentNullException(nameof(entry)); diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs new file mode 100644 index 0000000000..21973d9e61 --- /dev/null +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs @@ -0,0 +1,91 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace SIL.FieldWorks.Common.FwAvalonia.Region +{ + /// + /// Projects a typed into a value-bound + /// (task 4.8). It flattens the visible leaf field nodes, + /// classifies each into a from its editor, and asks the supplied + /// for live values. This is the typed-definition-backed + /// replacement for the lossy hand-written POC DTO mapping: structure is owned by the view + /// definition, not by a bespoke three-field projection. + /// + public static class LexicalEditRegionMapper + { + public static LexicalEditRegionModel FromViewDefinition(ViewDefinitionModel definition, IRegionValueProvider values) + { + if (definition == null) throw new System.ArgumentNullException(nameof(definition)); + if (values == null) throw new System.ArgumentNullException(nameof(values)); + + var fields = new List(); + foreach (var root in definition.Roots) + { + CollectFields(root, values, fields); + } + + return new LexicalEditRegionModel(definition.ClassName, definition.LayoutName, fields, definition.Diagnostics); + } + + private static void CollectFields(ViewNode node, IRegionValueProvider values, List output) + { + if (node.Kind == ViewNodeKind.Field && node.Visibility != ViewVisibility.Never) + { + output.Add(BuildField(node, values)); + } + + foreach (var child in node.Children) + { + CollectFields(child, values, output); + } + } + + private static LexicalEditRegionField BuildField(ViewNode node, IRegionValueProvider values) + { + var kind = ClassifyKind(node); + IReadOnlyList wsValues = null; + IReadOnlyList options = null; + string selected = null; + + switch (kind) + { + case RegionFieldKind.Text: + wsValues = values.GetValues(node); + break; + case RegionFieldKind.Chooser: + options = values.GetOptions(node); + selected = values.GetSelectedOptionKey(node); + break; + } + + return new LexicalEditRegionField( + node.StableId, node.Label, node.Field, node.WritingSystem, kind, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, wsValues, options, selected); + } + + /// + /// Maps a node's editor to a renderable kind. Heuristic and deliberately small for the first + /// slice; extend as more editors gain Avalonia renderers. Obsolete editors are unsupported; + /// atomic-reference/chooser editors render as choosers; everything else is treated as text. + /// + private static RegionFieldKind ClassifyKind(ViewNode node) + { + if (node.EditorClassification == EditorClassification.Obsolete) + return RegionFieldKind.Unsupported; + + var editor = node.RawEditor ?? string.Empty; + if (editor.IndexOf("atomicreference", System.StringComparison.OrdinalIgnoreCase) >= 0 + || editor.IndexOf("chooser", System.StringComparison.OrdinalIgnoreCase) >= 0 + || editor.IndexOf("morphtype", System.StringComparison.OrdinalIgnoreCase) >= 0) + { + return RegionFieldKind.Chooser; + } + + return RegionFieldKind.Text; + } + } +} diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs new file mode 100644 index 0000000000..9ad83bd1ac --- /dev/null +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs @@ -0,0 +1,148 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace SIL.FieldWorks.Common.FwAvalonia.Region +{ + /// + /// The renderable kind of a region field, derived from the typed view definition's editor + /// classification/editor string rather than hard-coded per field (task 4.8). Extensible: unknown + /// known-editors map to for the first slice; obsolete editors map to + /// . + /// + public enum RegionFieldKind + { + /// A (possibly multi-writing-system) text editor. + Text, + + /// An atomic reference / chooser editor. + Chooser, + + /// An editor with no supported region rendering (renders an unsupported state). + Unsupported + } + + /// One writing-system alternative's value plus the font hints needed to render it. + public sealed class RegionWsValue + { + public RegionWsValue(string wsAbbrev, string value, string fontFamily = null, double fontSize = 0) + { + WsAbbrev = wsAbbrev; + Value = value; + FontFamily = fontFamily; + FontSize = fontSize; + } + + public string WsAbbrev { get; } + public string Value { get; } + public string FontFamily { get; } + public double FontSize { get; } + } + + /// A chooser option (key + display name). + public sealed class RegionChoiceOption + { + public RegionChoiceOption(string key, string name) + { + Key = key; + Name = name; + } + + public string Key { get; } + public string Name { get; } + } + + /// + /// A field on a lexical-edit region, projected from a typed and bound to live + /// values by an . This is the product contract that replaces the + /// lossy hand-written POC DTO: structure comes from the typed view definition, values from the + /// provider, so the region scales to arbitrary layouts instead of three fixed fields. + /// + public sealed class LexicalEditRegionField + { + public LexicalEditRegionField( + string stableId, + string label, + string field, + string writingSystem, + RegionFieldKind kind, + EditorClassification editorClassification, + string automationId, + string localizationKey, + SurfaceRouting routing, + IReadOnlyList values, + IReadOnlyList options, + string selectedOptionKey) + { + StableId = stableId; + Label = label; + Field = field; + WritingSystem = writingSystem; + Kind = kind; + EditorClassification = editorClassification; + AutomationId = automationId; + LocalizationKey = localizationKey; + Routing = routing; + Values = values ?? new List(); + Options = options ?? new List(); + SelectedOptionKey = selectedOptionKey; + } + + public string StableId { get; } + public string Label { get; } + public string Field { get; } + public string WritingSystem { get; } + public RegionFieldKind Kind { get; } + public EditorClassification EditorClassification { get; } + public string AutomationId { get; } + public string LocalizationKey { get; } + public SurfaceRouting Routing { get; } + public IReadOnlyList Values { get; } + public IReadOnlyList Options { get; } + public string SelectedOptionKey { get; } + } + + /// + /// A flattened, value-bound region projected from a typed . Carries + /// the source diagnostics so unsupported constructs are surfaced, not silently dropped. + /// + public sealed class LexicalEditRegionModel + { + public LexicalEditRegionModel( + string className, + string layoutName, + IReadOnlyList fields, + IReadOnlyList diagnostics) + { + ClassName = className; + LayoutName = layoutName; + Fields = fields ?? new List(); + Diagnostics = diagnostics ?? new List(); + } + + public string ClassName { get; } + public string LayoutName { get; } + public IReadOnlyList Fields { get; } + public IReadOnlyList Diagnostics { get; } + } + + /// + /// Supplies live field values/options for a region field, keyed by the typed source node. The + /// implementation lives at the product edge (LCModel-backed in xWorks; faked in tests), keeping this + /// FwAvalonia layer free of any LCModel dependency. + /// + public interface IRegionValueProvider + { + /// The per-writing-system values for a text field node. + IReadOnlyList GetValues(ViewNode fieldNode); + + /// The selectable options for a chooser field node. + IReadOnlyList GetOptions(ViewNode fieldNode); + + /// The currently selected option key for a chooser field node. + string GetSelectedOptionKey(ViewNode fieldNode); + } +} diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs new file mode 100644 index 0000000000..8ad43a9018 --- /dev/null +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs @@ -0,0 +1,167 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace SIL.FieldWorks.Common.FwAvalonia.Region +{ + /// + /// A data-driven Avalonia view that renders a (task 4.8). Unlike + /// PocLexEntrySlice, which hard-codes three fields over a detached DTO, this view builds one + /// row per region field from the typed view definition, so the same renderer scales as more fields + /// are added to the definition. Each field's renderer is chosen from its . + /// Stable, nonlocalized automation ids come from the field (falling back to the stable node id). + /// Editing write-back is intentionally deferred to the LCModel-backed edit session (tasks 6.x): this + /// view binds and displays values; commit/cancel through IEditSession is the next step. + /// + public sealed class LexicalEditRegionView : UserControl + { + public LexicalEditRegionView(LexicalEditRegionModel model) + { + Model = model ?? throw new System.ArgumentNullException(nameof(model)); + + Name = "LexicalEditRegionView"; + AutomationProperties.SetAutomationId(this, "LexicalEditRegionView"); + AutomationProperties.SetName(this, "Lexical Edit Region"); + + var grid = new Grid + { + Margin = PocDensity.SliceMargin, + ColumnDefinitions = new ColumnDefinitions + { + new ColumnDefinition(PocDensity.LabelColumnWidth, GridUnitType.Pixel), + new ColumnDefinition(GridLength.Star) + } + }; + + for (var i = 0; i < model.Fields.Count; i++) + { + grid.RowDefinitions.Add(new RowDefinition(GridLength.Auto)); + AddField(grid, i, model.Fields[i]); + } + + Content = grid; + } + + /// The region model this view renders. + public LexicalEditRegionModel Model { get; } + + private static void AddField(Grid grid, int row, LexicalEditRegionField field) + { + var automationId = string.IsNullOrEmpty(field.AutomationId) ? field.StableId : field.AutomationId; + + var labelBlock = new TextBlock + { + Text = field.Label ?? field.Field ?? string.Empty, + Margin = new Thickness(0, 0, 6, PocDensity.FieldSpacing), + VerticalAlignment = VerticalAlignment.Top, + TextAlignment = TextAlignment.Right, + Foreground = Brushes.Black + }; + AutomationProperties.SetAutomationId(labelBlock, automationId + ".Label"); + AutomationProperties.SetName(labelBlock, field.Label ?? field.Field ?? string.Empty); + Grid.SetRow(labelBlock, row); + Grid.SetColumn(labelBlock, 0); + grid.Children.Add(labelBlock); + + var editor = BuildEditor(field, automationId); + editor.Margin = new Thickness(0, 0, 0, PocDensity.FieldSpacing); + Grid.SetRow(editor, row); + Grid.SetColumn(editor, 1); + grid.Children.Add(editor); + } + + private static Control BuildEditor(LexicalEditRegionField field, string automationId) + { + switch (field.Kind) + { + case RegionFieldKind.Chooser: + return BuildChooser(field, automationId); + case RegionFieldKind.Unsupported: + return BuildUnsupported(field, automationId); + default: + return BuildText(field, automationId); + } + } + + private static Control BuildText(LexicalEditRegionField field, string automationId) + { + var stack = new StackPanel { Spacing = PocDensity.RowSpacing }; + AutomationProperties.SetAutomationId(stack, automationId); + AutomationProperties.SetName(stack, field.Label ?? field.Field ?? automationId); + + foreach (var value in field.Values) + { + var abbrev = new TextBlock + { + Text = value.WsAbbrev, + Width = PocDensity.WsAbbrevWidth, + VerticalAlignment = VerticalAlignment.Center, + Foreground = Brushes.Gray + }; + + var box = new TextBox + { + Text = value.Value, + Padding = PocDensity.EditorPadding, + MinHeight = 0, + AcceptsReturn = false + }; + if (!string.IsNullOrEmpty(value.FontFamily)) + box.FontFamily = new FontFamily(value.FontFamily); + if (value.FontSize > 0) + box.FontSize = value.FontSize; + AutomationProperties.SetAutomationId(box, automationId + "." + value.WsAbbrev); + AutomationProperties.SetName(box, (field.Label ?? automationId) + " " + value.WsAbbrev); + + var rowPanel = new DockPanel(); + DockPanel.SetDock(abbrev, Dock.Left); + rowPanel.Children.Add(abbrev); + rowPanel.Children.Add(box); + stack.Children.Add(rowPanel); + } + + return stack; + } + + private static Control BuildChooser(LexicalEditRegionField field, string automationId) + { + var names = field.Options.Select(o => o.Name).ToList(); + var combo = new ComboBox + { + ItemsSource = names, + Padding = PocDensity.EditorPadding, + MinHeight = 0 + }; + + var selected = field.Options.FirstOrDefault(o => o.Key == field.SelectedOptionKey); + if (selected != null) + combo.SelectedItem = selected.Name; + + AutomationProperties.SetAutomationId(combo, automationId); + AutomationProperties.SetName(combo, field.Label ?? field.Field ?? automationId); + return combo; + } + + private static Control BuildUnsupported(LexicalEditRegionField field, string automationId) + { + var block = new TextBlock + { + Text = $"(unsupported editor: {field.EditorClassification})", + Foreground = Brushes.Gray, + VerticalAlignment = VerticalAlignment.Center + }; + AutomationProperties.SetAutomationId(block, automationId); + AutomationProperties.SetName(block, field.Label ?? field.Field ?? automationId); + return block; + } + } +} diff --git a/Src/Common/FwAvalonia/Seams/ActiveHostContract.cs b/Src/Common/FwAvalonia/Seams/ActiveHostContract.cs new file mode 100644 index 0000000000..789655d6c8 --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/ActiveHostContract.cs @@ -0,0 +1,66 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// + /// The active-host contract for a migrated region (task 3.10): the visible Avalonia path SHALL NOT + /// instantiate or drive hidden legacy DataTree/menu infrastructure, except through an + /// explicitly approved baseline adapter used only for comparison or fallback. This type makes the + /// rule data so a host can ask "may I drive the legacy DataTree right now?" and an audit test can + /// assert the answer. Adapter ids are the manifest's allowedAdapters entries. + /// + public sealed class ActiveHostContract + { + private readonly HashSet _allowedBaselineAdapters; + + public ActiveHostContract(LexicalEditSurfaceKind activeSurface, IEnumerable allowedBaselineAdapters = null) + { + ActiveSurface = activeSurface; + _allowedBaselineAdapters = new HashSet( + allowedBaselineAdapters ?? Enumerable.Empty(), StringComparer.Ordinal); + } + + /// The surface that is currently visible/active. + public LexicalEditSurfaceKind ActiveSurface { get; } + + /// Baseline-only adapter ids that are permitted to touch legacy infrastructure even when Avalonia is active. + public IReadOnlyCollection AllowedBaselineAdapters => _allowedBaselineAdapters; + + /// + /// Whether legacy DataTree initialization/driving is permitted in the current state. Always + /// true when the legacy surface is active; when Avalonia is active it is permitted only for an + /// approved baseline adapter id. + /// + public bool PermitsLegacyDataTreeDrive(string adapterId = null) + { + if (ActiveSurface == LexicalEditSurfaceKind.Legacy) + return true; + + return adapterId != null && _allowedBaselineAdapters.Contains(adapterId); + } + + /// Throws if legacy DataTree driving is not permitted in the current state. + public void AssertLegacyDataTreeDriveAllowed(string adapterId = null) + { + if (!PermitsLegacyDataTreeDrive(adapterId)) + { + throw new InvalidOperationException( + $"Active-host contract violation: the Avalonia surface is active and may not drive the legacy " + + $"DataTree (adapter id '{adapterId ?? ""}' is not an approved baseline adapter)."); + } + } + + /// A contract for a legacy-active host (everything permitted). + public static ActiveHostContract ForLegacy() => new ActiveHostContract(LexicalEditSurfaceKind.Legacy); + + /// A contract for an Avalonia-active host with the given approved baseline adapters (none by default). + public static ActiveHostContract ForAvalonia(params string[] allowedBaselineAdapters) + => new ActiveHostContract(LexicalEditSurfaceKind.Avalonia, allowedBaselineAdapters); + } +} diff --git a/Src/Common/FwAvalonia/Seams/IHostSurface.cs b/Src/Common/FwAvalonia/Seams/IHostSurface.cs new file mode 100644 index 0000000000..fc90438c05 --- /dev/null +++ b/Src/Common/FwAvalonia/Seams/IHostSurface.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.Seams +{ + /// Which framework renders a lexical-edit surface. + public enum LexicalEditSurfaceKind + { + /// The legacy WinForms DataTree/Slice surface. + Legacy, + + /// The Avalonia surface. + Avalonia + } + + /// + /// Host/surface contract for the Lexical Edit screen (task 3.5). Captures the responsibilities + /// RecordEditView currently performs inline — surface initialization, showing a record, + /// focus, context menus, and view replacement — so the active-host contract (task 3.10) can be + /// stated and tested without reaching into WinForms internals. A surface is one renderable + /// implementation (legacy DataTree or an Avalonia host); the host owns the surfaces and swaps + /// between them under the app-wide UI mode. + /// + public interface ILexicalEditSurface + { + /// Which framework this surface is. + LexicalEditSurfaceKind Kind { get; } + + /// Whether the surface has performed its (lazy) one-time initialization. + bool IsInitialized { get; } + + /// + /// Performs one-time initialization (control creation, persistence, menus, layout inventory). + /// Idempotent. Per the active-host contract, the host must only initialize the surface it is + /// about to make active, except for an explicitly approved baseline adapter. + /// + void EnsureInitialized(); + + /// Shows the given record on this surface. The record is opaque to the contract. + void ShowRecord(object record); + + /// Hides this surface (used when another surface becomes active). + void Hide(); + + /// Attempts to put input focus on this surface; returns whether focus was taken. + bool TrySetFocus(); + + /// Attempts to show a context menu for the given context; returns whether one was shown. + bool TryShowContextMenu(object context); + + /// Called before the host replaces this surface with another (commit/flush hook). + void PrepareToReplace(); + } + + /// + /// The screen that owns lexical-edit surfaces and replaces the active one under the UI mode + /// (task 3.5). Implemented by RecordEditView in the product app. + /// + public interface ILexicalEditHost + { + /// The currently active surface kind. + LexicalEditSurfaceKind ActiveSurface { get; } + + /// The surfaces this host owns. + IReadOnlyList Surfaces { get; } + + /// + /// Makes the given surface active and shows the record on it, hiding the previously active + /// surface. Honors the active-host contract: the inactive surface is not driven. + /// + void ReplaceSurface(LexicalEditSurfaceKind kind, object record); + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 528071ebca..391ca66f36 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -93,6 +93,27 @@ public enum ViewDiagnosticSeverity Error } + /// + /// Product-vs-preview routing for a node that can appear on a globally switchable surface (task 4.7). + /// Inherit defers to the region/host decision; Product is wired through real + /// LCModel-backed contracts; Preview is preview-host/sample only; Unsupported renders a + /// resource-backed unsupported state instead of pretending to be a product editor. + /// + public enum SurfaceRouting + { + /// Defer to the enclosing region/host routing decision (default). + Inherit, + + /// Product surface: must use real edit-session/domain contracts. + Product, + + /// Preview/sample only: detached DTO models are allowed. + Preview, + + /// Explicitly unsupported: render a resource-backed unsupported state. + Unsupported + } + /// /// A diagnostic raised while importing/compiling a view definition. Carries the layout part and /// node path so unsupported constructs are reported, not silently dropped (task 4.4 / 3.8). @@ -141,7 +162,10 @@ public ViewNode( ViewExpansion expansion, bool indented, string targetLayout, - IReadOnlyList children) + IReadOnlyList children, + string localizationKey = null, + string automationId = null, + SurfaceRouting routing = SurfaceRouting.Inherit) { StableId = stableId; Kind = kind; @@ -156,6 +180,9 @@ public ViewNode( Indented = indented; TargetLayout = targetLayout; Children = children ?? (IReadOnlyList)Array.Empty(); + LocalizationKey = localizationKey; + AutomationId = automationId; + Routing = routing; } /// Deterministic identity derived from the node's path (stable across realizations). @@ -186,6 +213,22 @@ public ViewNode( public string TargetLayout { get; } public IReadOnlyList Children { get; } + + /// + /// Optional localization/resource key for this node's user-facing text (task 4.7). Null when the + /// source carries no key; the label is then treated as a literal. Carried so a globally switchable + /// surface can resolve localized strings without re-deriving them from incidental layout text. + /// + public string LocalizationKey { get; } + + /// + /// Optional stable, nonlocalized accessibility identity for this node (task 4.7), stamped on the + /// rendered control's AutomationProperties.AutomationId. Null when not authored. + /// + public string AutomationId { get; } + + /// Product-vs-preview routing for this node (task 4.7). Defaults to . + public SurfaceRouting Routing { get; } } /// @@ -248,11 +291,25 @@ private static void AppendNode(StringBuilder sb, ViewNode node, int depth) $"{indent}{node.StableId} | {node.Kind} | label={node.Label} | field={node.Field} | " + $"editor={node.RawEditor}({node.EditorClassification}) | ws={node.WritingSystem} | " + $"vis={node.Visibility} | exp={node.Expansion} | indent={(node.Indented ? "1" : "0")} | " + - $"target={node.TargetLayout}"); + $"target={node.TargetLayout}{AppendMetadata(node)}"); foreach (var child in node.Children) { AppendNode(sb, child, depth + 1); } } + + // Task 4.7 metadata is appended only when present so existing semantic baselines (which carry no + // localization/accessibility/routing metadata) keep their exact snapshot. + private static string AppendMetadata(ViewNode node) + { + var sb = new StringBuilder(); + if (!string.IsNullOrEmpty(node.LocalizationKey)) + sb.Append($" | loc={node.LocalizationKey}"); + if (!string.IsNullOrEmpty(node.AutomationId)) + sb.Append($" | autoId={node.AutomationId}"); + if (node.Routing != SurfaceRouting.Inherit) + sb.Append($" | routing={node.Routing}"); + return sb.ToString(); + } } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index 1639360161..d3a124ebf7 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -126,6 +126,13 @@ private ViewNode BuildNode( var field = Attr(contentEl, "field"); var ws = Attr(contentEl, "ws"); + // Task 4.7 metadata. Legacy XML Parts/Layout does not carry these, so they stay null/Inherit for + // imported layouts (preserving semantic baselines); authored or region-spec sources may set them. + var localizationKey = Attr(callerEl, "localizationKey") ?? Attr(contentEl, "localizationKey") + ?? Attr(callerEl, "labelId") ?? Attr(contentEl, "labelId"); + var automationId = Attr(callerEl, "automationId") ?? Attr(contentEl, "automationId"); + var routing = ParseRouting(Attr(callerEl, "surface") ?? Attr(contentEl, "surface")); + switch (contentEl.Name.LocalName) { case "slice": @@ -148,11 +155,13 @@ private ViewNode BuildNode( var children = new List(); BuildInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); return new ViewNode(stableId, ViewNodeKind.Group, label, abbreviation, field, editor, - classification, ws, visibility, expansion, indented, null, children); + classification, ws, visibility, expansion, indented, null, children, + localizationKey, automationId, routing); } return MakeLeaf(stableId, ViewNodeKind.Field, label, abbreviation, field, editor, - classification, ws, visibility, expansion, indented, null); + classification, ws, visibility, expansion, indented, null, + localizationKey, automationId, routing); } case "obj": case "seq": @@ -162,7 +171,8 @@ private ViewNode BuildNode( var children = new List(); BuildInjectedChildren(callerEl, parts, layoutType, stableId, children, diagnostics); return new ViewNode(stableId, kind, label, abbreviation, field, null, - EditorClassification.GroupingNone, ws, visibility, expansion, indented, targetLayout, children); + EditorClassification.GroupingNone, ws, visibility, expansion, indented, targetLayout, children, + localizationKey, automationId, routing); } default: diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "unknown-part-content", @@ -251,12 +261,24 @@ private static void RaiseEditorDiagnostics( private static ViewNode MakeLeaf( string stableId, ViewNodeKind kind, string label, string abbreviation, string field, string editor, EditorClassification classification, string ws, ViewVisibility visibility, ViewExpansion expansion, - bool indented, string targetLayout) + bool indented, string targetLayout, + string localizationKey = null, string automationId = null, SurfaceRouting routing = SurfaceRouting.Inherit) => new ViewNode(stableId, kind, label, abbreviation, field, editor, classification, ws, visibility, - expansion, indented, targetLayout, System.Array.Empty()); + expansion, indented, targetLayout, System.Array.Empty(), localizationKey, automationId, routing); private static string Attr(XElement el, string name) => (string)el.Attribute(name); + private static SurfaceRouting ParseRouting(string value) + { + switch (value) + { + case "product": return SurfaceRouting.Product; + case "preview": return SurfaceRouting.Preview; + case "unsupported": return SurfaceRouting.Unsupported; + default: return SurfaceRouting.Inherit; + } + } + private static ViewVisibility ParseVisibility(string value) { switch (value) diff --git a/Src/Common/FwUtils/Properties/Settings.Designer.cs b/Src/Common/FwUtils/Properties/Settings.Designer.cs index 51840f7ecc..69e27feedb 100644 --- a/Src/Common/FwUtils/Properties/Settings.Designer.cs +++ b/Src/Common/FwUtils/Properties/Settings.Designer.cs @@ -9,20 +9,20 @@ //------------------------------------------------------------------------------ namespace SIL.FieldWorks.Common.FwUtils.Properties { - - + + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "15.9.0.0")] internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase { - + private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings()))); - + public static Settings Default { get { return defaultInstance; } } - + /// /// Setting controlling the updating of the Global WS Store /// @@ -39,7 +39,7 @@ public bool UpdateGlobalWSStore { this["UpdateGlobalWSStore"] = value; } } - + /// /// Setting to store Reporting options /// @@ -55,7 +55,7 @@ public bool UpdateGlobalWSStore { this["Reporting"] = value; } } - + /// /// Setting controlling getting updates /// @@ -88,7 +88,7 @@ public string UIMode { this["UIMode"] = value; } } - + /// /// Setting indicating that the Settings need to be upgraded /// @@ -105,7 +105,7 @@ public bool CallUpgrade { this["CallUpgrade"] = value; } } - + /// /// Setting to store current keyboard assignments for writing systems /// @@ -122,7 +122,7 @@ public string LocalKeyboards { this["LocalKeyboards"] = value; } } - + /// /// Stores last used user for webonary export /// @@ -139,7 +139,7 @@ public string WebonaryUser { this["WebonaryUser"] = value; } } - + /// /// stores last password for webonary export /// diff --git a/Src/LexText/LexTextControls/LexOptionsDlg.cs b/Src/LexText/LexTextControls/LexOptionsDlg.cs index e06d8bfed0..64ee32258b 100644 --- a/Src/LexText/LexTextControls/LexOptionsDlg.cs +++ b/Src/LexText/LexTextControls/LexOptionsDlg.cs @@ -46,6 +46,7 @@ public partial class LexOptionsDlg : Form, IFwExtension private GroupBox m_uiModeGroup; private Label m_uiModeLabel; private ComboBox m_uiModeChooser; + private Button m_restartToApplyButton; private FwApp App => m_propertyTable?.GetValue("App") ?? m_helpTopicProvider as FwApp; public LexOptionsDlg() @@ -83,6 +84,7 @@ protected override void OnLoad(EventArgs e) m_autoOpenCheckBox.Checked = AutoOpenLastProject; m_okToPingCheckBox.Checked = m_settings.Reporting.OkToPingBasicUsageData; SelectUIMode(NormalizeUIMode(m_settings.UIMode)); + UpdateRestartToApplyButtonState(); if (Platform.IsWindows) { if (m_settings.Update == null) @@ -141,6 +143,7 @@ private void m_btnOK_Click(object sender, EventArgs e) var newUiMode = SelectedUIMode; if (oldUiMode != newUiMode) { + restartRequired = true; m_settings.UIMode = newUiMode; if (m_propertyTable != null) { @@ -270,10 +273,15 @@ private void m_btnOK_Click(object sender, EventArgs e) Close(); if(restartRequired) { - MessageBox.Show(Owner, LexTextControls.RestartToForSettingsToTakeEffect_Content, LexTextControls.RestartToForSettingsToTakeEffect_Title); + ShowRestartRequiredPrompt(); } } + protected virtual void ShowRestartRequiredPrompt() + { + MessageBox.Show(Owner, LexTextControls.RestartToForSettingsToTakeEffect_Content, LexTextControls.RestartToForSettingsToTakeEffect_Title); + } + /// /// If this is true and there is a last edited project name stored, FieldWorks will /// open that project automatically instead of displaying the usual Welcome dialog. @@ -438,6 +446,7 @@ private void InitializeUIModeControls() m_uiModeGroup = new GroupBox(); m_uiModeLabel = new Label(); m_uiModeChooser = new ComboBox(); + m_restartToApplyButton = new Button(); m_uiModeGroup.Text = GetOptionString("UiModeGroupTitle", "Lexical Edit UI:"); m_uiModeGroup.Left = groupBox1.Left; @@ -455,13 +464,27 @@ private void InitializeUIModeControls() m_uiModeChooser.DropDownStyle = ComboBoxStyle.DropDownList; m_uiModeChooser.Left = 6; m_uiModeChooser.Top = 38; - m_uiModeChooser.Width = m_userInterfaceChooser.Width; m_uiModeChooser.Name = "m_uiModeChooser"; + m_uiModeChooser.SelectedIndexChanged += m_uiModeChooser_SelectedIndexChanged; + + m_restartToApplyButton.Name = "m_restartToApplyButton"; + m_restartToApplyButton.Text = GetOptionString("UiModeRestartToApply", "Restart to apply"); + m_restartToApplyButton.UseVisualStyleBackColor = true; + m_restartToApplyButton.Enabled = false; + m_restartToApplyButton.Click += m_restartToApplyButton_Click; + + var buttonWidth = Math.Max(110, TextRenderer.MeasureText(m_restartToApplyButton.Text, m_restartToApplyButton.Font).Width + 12); + m_restartToApplyButton.Width = buttonWidth; + m_restartToApplyButton.Height = m_uiModeChooser.Height + 2; + m_restartToApplyButton.Top = 37; + m_uiModeChooser.Width = Math.Max(120, m_uiModeGroup.Width - 18 - buttonWidth - 6); m_uiModeChooser.Items.Add(new UiModeMenuItem(LegacyUIMode, GetOptionString("UiModeLegacy", "Legacy"))); m_uiModeChooser.Items.Add(new UiModeMenuItem(NewUIMode, GetOptionString("UiModeNew", "New"))); + m_restartToApplyButton.Left = m_uiModeChooser.Right + 6; m_uiModeGroup.Controls.Add(m_uiModeLabel); m_uiModeGroup.Controls.Add(m_uiModeChooser); + m_uiModeGroup.Controls.Add(m_restartToApplyButton); m_tabInterface.Controls.Add(m_uiModeGroup); var delta = m_uiModeGroup.Bottom + 8 - label4.Top; @@ -486,6 +509,19 @@ private string SelectedUIMode } } + private void m_uiModeChooser_SelectedIndexChanged(object sender, EventArgs e) + { + UpdateRestartToApplyButtonState(); + } + + private void m_restartToApplyButton_Click(object sender, EventArgs e) + { + if (!IsUIModeRestartPending) + return; + + m_btnOK_Click(sender, e); + } + private void SelectUIMode(string mode) { var desired = NormalizeUIMode(mode); @@ -507,6 +543,25 @@ private static string NormalizeUIMode(string mode) : LegacyUIMode; } + private bool IsUIModeRestartPending + { + get + { + if (m_settings == null) + return false; + + return NormalizeUIMode(m_settings.UIMode) != SelectedUIMode; + } + } + + private void UpdateRestartToApplyButtonState() + { + if (m_restartToApplyButton == null) + return; + + m_restartToApplyButton.Enabled = IsUIModeRestartPending; + } + private static string GetOptionString(string resourceName, string fallback) { var value = LexTextControls.ResourceManager.GetString(resourceName, LexTextControls.Culture); diff --git a/Src/LexText/LexTextControls/LexTextControls.resx b/Src/LexText/LexTextControls/LexTextControls.resx index 9c6306dd43..fca69ed935 100644 --- a/Src/LexText/LexTextControls/LexTextControls.resx +++ b/Src/LexText/LexTextControls/LexTextControls.resx @@ -1,17 +1,17 @@ - @@ -1329,6 +1329,10 @@ Sorry, in this browser, opening the file via hyperlink presents a security risk. New Uses the new Avalonia lexical edit UI. + + Restart to apply + Inline button text shown next to the lexical edit UI mode chooser when a restart is needed to fully apply the selected mode. + Choose phonological features diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/LexOptionsDlgTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/LexOptionsDlgTests.cs new file mode 100644 index 0000000000..4ca2fd0d7e --- /dev/null +++ b/Src/LexText/LexTextControls/LexTextControlsTests/LexOptionsDlgTests.cs @@ -0,0 +1,282 @@ +using System; +using System.Reflection; +using System.Resources; +using System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.Reporting; +using SIL.Settings; +using XCore; + +namespace LexTextControlsTests +{ + [TestFixture] + [Apartment(System.Threading.ApartmentState.STA)] + public class LexOptionsDlgTests + { + [Test] + public void OkClick_SavesUIModeAndMirrorsItIntoPropertyTable() + { + var settings = CreateSettings("Legacy"); + using (var mediator = new Mediator()) + using (var propertyTable = new PropertyTable(mediator)) + using (var dlg = new TestableLexOptionsDlg()) + { + // Use the existing bare-bones path, then inject the test doubles. + dlg.InitBareBones(null); + SetPrivateField(dlg, "m_settings", settings); + SetPrivateField(dlg, "m_propertyTable", propertyTable); + InvokeOnLoad(dlg); + + var combo = (ComboBox)FindControlRecursive(dlg, "m_uiModeChooser"); + Assert.That(combo, Is.Not.Null); + combo.SelectedIndex = 1; // New + + InvokeOk(dlg); + + Assert.That(settings.UIMode, Is.EqualTo("New")); + Assert.That(settings.SaveCalls, Is.EqualTo(1)); + Assert.That(propertyTable.GetStringProperty("UIMode", "Legacy"), Is.EqualTo("New")); + Assert.That(dlg.RestartPromptCount, Is.EqualTo(1)); + } + } + + [Test] + public void OkClick_LeavesLegacyWhenUserDoesNotChangeSelection() + { + var settings = CreateSettings("Legacy"); + using (var mediator = new Mediator()) + using (var propertyTable = new PropertyTable(mediator)) + using (var dlg = new TestableLexOptionsDlg()) + { + dlg.InitBareBones(null); + SetPrivateField(dlg, "m_settings", settings); + SetPrivateField(dlg, "m_propertyTable", propertyTable); + InvokeOnLoad(dlg); + + InvokeOk(dlg); + + Assert.That(settings.UIMode, Is.EqualTo("Legacy")); + Assert.That(settings.SaveCalls, Is.EqualTo(1)); + Assert.That(propertyTable.GetStringProperty("UIMode", "Legacy"), Is.EqualTo("Legacy")); + Assert.That(dlg.RestartPromptCount, Is.EqualTo(0)); + } + } + + [Test] + public void RestartToApplyButton_EnablesOnlyWhenUIModeChanges() + { + var settings = CreateSettings("Legacy"); + using (var mediator = new Mediator()) + using (var propertyTable = new PropertyTable(mediator)) + using (var dlg = new TestableLexOptionsDlg()) + { + dlg.InitBareBones(null); + SetPrivateField(dlg, "m_settings", settings); + SetPrivateField(dlg, "m_propertyTable", propertyTable); + InvokeOnLoad(dlg); + + var combo = (ComboBox)FindControlRecursive(dlg, "m_uiModeChooser"); + var restartButton = (Button)FindControlRecursive(dlg, "m_restartToApplyButton"); + Assert.That(combo, Is.Not.Null); + Assert.That(restartButton, Is.Not.Null); + Assert.That(restartButton.Enabled, Is.False); + + combo.SelectedIndex = 1; + Assert.That(restartButton.Enabled, Is.True); + + combo.SelectedIndex = 0; + Assert.That(restartButton.Enabled, Is.False); + } + } + + [Test] + public void RestartToApplyButton_ClickSavesChangedUIMode() + { + var settings = CreateSettings("Legacy"); + using (var mediator = new Mediator()) + using (var propertyTable = new PropertyTable(mediator)) + using (var dlg = new TestableLexOptionsDlg()) + { + dlg.InitBareBones(null); + SetPrivateField(dlg, "m_settings", settings); + SetPrivateField(dlg, "m_propertyTable", propertyTable); + InvokeOnLoad(dlg); + + var combo = (ComboBox)FindControlRecursive(dlg, "m_uiModeChooser"); + var restartButton = (Button)FindControlRecursive(dlg, "m_restartToApplyButton"); + Assert.That(combo, Is.Not.Null); + Assert.That(restartButton, Is.Not.Null); + + combo.SelectedIndex = 1; + InvokeRestartToApply(dlg); + + Assert.That(settings.UIMode, Is.EqualTo("New")); + Assert.That(settings.SaveCalls, Is.EqualTo(1)); + Assert.That(propertyTable.GetStringProperty("UIMode", "Legacy"), Is.EqualTo("New")); + Assert.That(dlg.RestartPromptCount, Is.EqualTo(1)); + } + } + + [Test] + public void UIModeControls_ReadDisplayTextFromResx() + { + var settings = CreateSettings("Legacy"); + using (var mediator = new Mediator()) + using (var propertyTable = new PropertyTable(mediator)) + using (var dlg = new TestableLexOptionsDlg()) + { + dlg.InitBareBones(null); + SetPrivateField(dlg, "m_settings", settings); + SetPrivateField(dlg, "m_propertyTable", propertyTable); + InvokeOnLoad(dlg); + + var group = (GroupBox)FindControlRecursive(dlg, "m_uiModeGroup"); + var label = (Label)FindControlRecursive(dlg, "m_uiModeLabel"); + var combo = (ComboBox)FindControlRecursive(dlg, "m_uiModeChooser"); + var restartButton = (Button)FindControlRecursive(dlg, "m_restartToApplyButton"); + + Assert.That(group, Is.Not.Null); + Assert.That(label, Is.Not.Null); + Assert.That(combo, Is.Not.Null); + Assert.That(restartButton, Is.Not.Null); + + Assert.That(group.Text, Is.EqualTo(ReadLexTextControlsResx("UiModeGroupTitle"))); + Assert.That(label.Text, Is.EqualTo(ReadLexTextControlsResx("UiModeLabel"))); + Assert.That(combo.Items[0].ToString(), Is.EqualTo(ReadLexTextControlsResx("UiModeLegacy"))); + Assert.That(combo.Items[1].ToString(), Is.EqualTo(ReadLexTextControlsResx("UiModeNew"))); + Assert.That(restartButton.Text, Is.EqualTo(ReadLexTextControlsResx("UiModeRestartToApply"))); + Assert.That(ReadLexTextControlsResx("RestartToForSettingsToTakeEffect_Title"), Is.Not.Empty); + Assert.That(ReadLexTextControlsResx("RestartToForSettingsToTakeEffect_Content"), Is.Not.Empty); + } + } + + private static void InvokeOk(Form dlg) + { + var method = FindMethod(dlg.GetType(), "m_btnOK_Click"); + Assert.That(method, Is.Not.Null); + method.Invoke(dlg, new object[] { null, EventArgs.Empty }); + } + + private static void InvokeOnLoad(Form dlg) + { + var method = FindMethod(dlg.GetType(), "OnLoad"); + Assert.That(method, Is.Not.Null); + method.Invoke(dlg, new object[] { EventArgs.Empty }); + } + + private static void InvokeRestartToApply(Form dlg) + { + var method = FindMethod(dlg.GetType(), "m_restartToApplyButton_Click"); + Assert.That(method, Is.Not.Null); + method.Invoke(dlg, new object[] { null, EventArgs.Empty }); + } + + private static TrackingTestFwApplicationSettings CreateSettings(string uiMode) + { + return new TrackingTestFwApplicationSettings + { + UIMode = uiMode, + Reporting = new ReportingSettings(), + Update = new UpdateSettings + { + Behavior = UpdateSettings.Behaviors.DoNotCheck, + Channel = UpdateSettings.Channels.Stable + } + }; + } + + private static Control FindControlRecursive(Control root, string name) + { + if (root == null) + return null; + if (root.Name == name) + return root; + foreach (Control child in root.Controls) + { + var found = FindControlRecursive(child, name); + if (found != null) + return found; + } + return null; + } + + private static void SetPrivateField(object target, string fieldName, object value) + { + var field = FindField(target.GetType(), fieldName); + Assert.That(field, Is.Not.Null, "Missing private field: " + fieldName); + field.SetValue(target, value); + } + + private static FieldInfo FindField(Type type, string fieldName) + { + while (type != null) + { + var field = type.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + if (field != null) + return field; + type = type.BaseType; + } + + return null; + } + + private static MethodInfo FindMethod(Type type, string methodName) + { + while (type != null) + { + var method = type.GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + if (method != null) + return method; + type = type.BaseType; + } + + return null; + } + + private static string ReadLexTextControlsResx(string key) + { + var resxPath = System.IO.Path.GetFullPath( + System.IO.Path.Combine( + TestContext.CurrentContext.TestDirectory, + "..", + "..", + "Src", + "LexText", + "LexTextControls", + "LexTextControls.resx")); + + using (var reader = new ResXResourceReader(resxPath)) + { + foreach (System.Collections.DictionaryEntry entry in reader) + { + if (string.Equals(entry.Key as string, key, StringComparison.Ordinal)) + return entry.Value as string ?? string.Empty; + } + } + + Assert.Fail("Missing LexTextControls.resx key: " + key); + return string.Empty; + } + + private sealed class TrackingTestFwApplicationSettings : TestFwApplicationSettings + { + public int SaveCalls { get; private set; } + + public override void Save() + { + SaveCalls++; + } + } + + private sealed class TestableLexOptionsDlg : SIL.FieldWorks.LexText.Controls.LexOptionsDlg + { + public int RestartPromptCount { get; private set; } + + protected override void ShowRestartRequiredPrompt() + { + RestartPromptCount++; + } + } + } +} diff --git a/Src/xWorks/LexicalEditRegionBuilder.cs b/Src/xWorks/LexicalEditRegionBuilder.cs new file mode 100644 index 0000000000..0c1a86b020 --- /dev/null +++ b/Src/xWorks/LexicalEditRegionBuilder.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Builds the product Lexical Edit region model from the typed view definition plus live LCModel + /// values (task 4.8). This is the typed-definition-backed replacement for + /// : structure is expressed as a + /// (the same IR vocabulary the XML importer produces), and this type only supplies values via + /// . The first-slice definition is authored here for the LexEntry + /// identity fields; the next step is to compile it from the live layout inventory so the region scales + /// to the full layout. Values are read on the UI thread; write-back goes through the LCModel edit + /// session (tasks 6.x), not this builder. + /// + public sealed class LexicalEditRegionBuilder : IRegionValueProvider + { + private const string LexemeFormField = "LexemeForm"; + private const string GlossField = "Gloss"; + private const string MorphTypeField = "MorphType"; + + private readonly ILexEntry _entry; + + private LexicalEditRegionBuilder(ILexEntry entry) + { + _entry = entry; + } + + /// + /// Builds a region model for the current record, or null if it is not a + /// (the caller then shows an explicit unsupported state). is reserved for + /// the writing-system/font service that will replace the placeholder ws abbreviations. + /// + public static LexicalEditRegionModel Build(ICmObject obj, LcmCache cache) + { + if (!(obj is ILexEntry entry)) + return null; + + var definition = BuildFirstSliceDefinition(); + var provider = new LexicalEditRegionBuilder(entry); + return LexicalEditRegionMapper.FromViewDefinition(definition, provider); + } + + /// + /// The typed view definition for the LexEntry identity first slice, expressed in the IR vocabulary + /// with stable ids, writing-system metadata, accessibility ids, and product routing. Authored for + /// now; replace with a live layout compile (ViewDefinitionCompiler) as the region grows. + /// + internal static ViewDefinitionModel BuildFirstSliceDefinition() + { + var roots = new List + { + Leaf("LexEntry/identity/#0", "Lexeme Form", LexemeFormField, "multistring", "vernacular", "LexemeFormEditor"), + Leaf("LexEntry/identity/#1", "Morph Type", MorphTypeField, "morphtypeatomicreference", null, "MorphTypeChooser"), + Leaf("LexEntry/identity/#2", "Gloss", GlossField, "multistring", "analysis", "SenseGlossEditor") + }; + + return new ViewDefinitionModel("LexEntry", "identity", "detail", roots, Array.Empty()); + } + + private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) + => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, + EditorClassification.Known, ws, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + localizationKey: null, automationId: automationId, routing: SurfaceRouting.Product); + + /// + public IReadOnlyList GetValues(ViewNode fieldNode) + { + switch (fieldNode.Field) + { + case LexemeFormField: + return new List { new RegionWsValue("vern", GetLexemeFormText()) }; + case GlossField: + return new List { new RegionWsValue("anal", GetFirstSenseGloss()) }; + default: + return new List(); + } + } + + /// + public IReadOnlyList GetOptions(ViewNode fieldNode) + { + if (fieldNode.Field != MorphTypeField) + return new List(); + + return new List + { + new RegionChoiceOption("stem", "stem"), + new RegionChoiceOption("root", "root"), + new RegionChoiceOption("prefix", "prefix"), + new RegionChoiceOption("suffix", "suffix") + }; + } + + /// + public string GetSelectedOptionKey(ViewNode fieldNode) + { + return fieldNode.Field == MorphTypeField ? GetMorphTypeKey() : null; + } + + private string GetLexemeFormText() + { + var lexemeText = _entry.LexemeFormOA?.Form != null + ? _entry.LexemeFormOA.Form.VernacularDefaultWritingSystem.Text + : string.Empty; + if (string.IsNullOrEmpty(lexemeText)) + lexemeText = _entry.CitationForm.VernacularDefaultWritingSystem.Text; + return lexemeText ?? string.Empty; + } + + private string GetFirstSenseGloss() + { + if (_entry.SensesOS.Count == 0) + return string.Empty; + return _entry.SensesOS[0].Gloss.AnalysisDefaultWritingSystem.Text ?? string.Empty; + } + + private string GetMorphTypeKey() + { + var type = _entry.LexemeFormOA?.MorphTypeRA; + if (type == null) + return "stem"; + if (type.Guid == MoMorphTypeTags.kguidMorphPrefix) + return "prefix"; + if (type.Guid == MoMorphTypeTags.kguidMorphSuffix) + return "suffix"; + if (type.Guid == MoMorphTypeTags.kguidMorphRoot || type.Guid == MoMorphTypeTags.kguidMorphBoundRoot) + return "root"; + return "stem"; + } + } +} diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index 527388bc98..c1eca16f25 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -64,6 +64,7 @@ public class RecordEditView : RecordView, IVwNotifyChange, IFocusablePanePortion private string m_printLayout; private LexicalEditSurface m_lexicalEditSurface; private readonly LexicalEditSurfaceFactory m_lexicalEditSurfaceFactory; + private readonly LexicalEditSurfaceSelectionService m_surfaceSelectionService = new LexicalEditSurfaceSelectionService(); private PocWinFormsHostControl m_avaloniaEntryForm; private bool m_legacySurfaceInitialized; @@ -343,9 +344,11 @@ bool ShowRecordOnIdle(object parameter) if (Clerk.CurrentObject == null || Clerk.SuspendLoadingRecordUntilOnJumpToRecord) { - if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) + if (ShouldUseAvaloniaLexicalEdit) { - m_dataEntryForm.Hide(); + // Active-host contract (task 3.10): do not touch the legacy DataTree while Avalonia is active. + if (m_avaloniaEntryForm == null) + EnsureAvaloniaSurfaceInitialized(); m_avaloniaEntryForm.Hide(); m_avaloniaEntryForm.Clear(); } @@ -358,22 +361,27 @@ bool ShowRecordOnIdle(object parameter) } try { - if (!m_legacySurfaceInitialized) + // Active-host contract (task 3.10): when the Avalonia surface is active we do NOT initialize + // or drive the legacy DataTree. Only the active surface is created and shown. + if (ShouldUseAvaloniaLexicalEdit) { - var localPersistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); - if (localPersistContext != "") - localPersistContext = m_vectorName + "." + localPersistContext + ".DataTree"; - else - localPersistContext = m_vectorName + ".DataTree"; - EnsureLegacySurfaceInitialized(localPersistContext); + if (m_avaloniaEntryForm == null) + EnsureAvaloniaSurfaceInitialized(); } - if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm == null) + else { - EnsureAvaloniaSurfaceInitialized(); + if (!m_legacySurfaceInitialized) + { + var localPersistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); + if (localPersistContext != "") + localPersistContext = m_vectorName + "." + localPersistContext + ".DataTree"; + else + localPersistContext = m_vectorName + ".DataTree"; + EnsureLegacySurfaceInitialized(localPersistContext); + } + m_dataEntryForm.Show(); } - if (!ShouldUseAvaloniaLexicalEdit) - m_dataEntryForm.Show(); // Enhance: Maybe do something here to allow changing the templates without the starting the application. ICmObject obj = Clerk.CurrentObject; @@ -386,14 +394,13 @@ bool ShowRecordOnIdle(object parameter) if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) { - m_dataEntryForm.ShowObject(obj, m_layoutName, m_layoutChoiceField, Clerk.CurrentObject, true); - m_dataEntryForm.Hide(); - m_avaloniaEntryForm.Show(); - var dto = LexicalEditPocMapper.CreateDto(obj, Cache); - if (dto == null) - m_avaloniaEntryForm.ShowMessage("Avalonia lexical-edit POC is currently available only for LexEntry records."); + // Task 4.8: the product route builds a typed-definition-backed region model, not the + // lossy POC DTO (which is now preview-host only). + var region = LexicalEditRegionBuilder.Build(obj, Cache); + if (region == null) + m_avaloniaEntryForm.ShowMessage("Avalonia lexical edit is currently available only for LexEntry records."); else - m_avaloniaEntryForm.ShowEntry(dto); + m_avaloniaEntryForm.ShowRegion(region); } else { @@ -425,11 +432,16 @@ private bool ShouldSuppressFocusChange(RecordNavigationInfo rni) private LexicalEditSurface ResolveConfiguredLexicalEditSurface() { + // Task 3.9: route the per-host decision through the explicit selection service rather than + // inferring product routing ad hoc from settings/PropertyTable state. var uiMode = m_propertyTable != null ? m_propertyTable.GetStringProperty(LexicalEditSurfaceResolver.UIModePropertyName, LexicalEditSurfaceResolver.LegacyUIMode) : LexicalEditSurfaceResolver.LegacyUIMode; + var toolName = m_propertyTable != null + ? m_propertyTable.GetStringProperty("currentContentControl", string.Empty) + : string.Empty; - return LexicalEditSurfaceResolver.Resolve(uiMode: uiMode); + return m_surfaceSelectionService.Decide(uiMode, toolName).Surface; } private void EnsureLegacySurfaceInitialized(string persistContext) @@ -439,7 +451,6 @@ private void EnsureLegacySurfaceInitialized(string persistContext) m_dataEntryForm.PersistenceProvder = new PersistenceProvider(m_mediator, m_propertyTable, persistContext); - Clerk.UpdateRecordTreeBarIfNeeded(); SetupSliceFilter(); m_dataEntryForm.Dock = DockStyle.Fill; m_dataEntryForm.SmallImages = m_propertyTable.GetValue("smallImages"); @@ -505,6 +516,11 @@ protected override void SetupDataContext() base.SetupDataContext(); + // InitBase() calls SetupDataContext() before RecordEditView.Init() resolves the surface, so + // resolve it here too — otherwise the first surface initialization would use the ctor default + // (WinForms) and the active-host contract (task 3.10) would be violated for an Avalonia start. + m_lexicalEditSurface = ResolveConfiguredLexicalEditSurface(); + //this will normally be the same name as the view, e.g. "basicEdit". This plus the name of the vector //should give us a unique context for the dataTree control parameters. @@ -515,16 +531,16 @@ protected override void SetupDataContext() else persistContext=m_vectorName+".DataTree"; - EnsureLegacySurfaceInitialized(persistContext); - if (ShouldUseAvaloniaLexicalEdit) - { - EnsureAvaloniaSurfaceInitialized(); - m_dataEntryForm.Hide(); - m_avaloniaEntryForm.Show(); - m_avaloniaEntryForm.BringToFront(); - } - else + // Surface-agnostic: the record list bar must update regardless of which detail surface is active. + Clerk.UpdateRecordTreeBarIfNeeded(); + + // Active-host contract (task 3.10): initialize only the active surface; the inactive surface is + // not instantiated or driven. The legacy DataTree is initialized here only when legacy is active; + // the Avalonia surface is created lazily in ShowRecordOnIdle so its construction stays on the + // idle path (the inactive legacy DataTree is never built). + if (!ShouldUseAvaloniaLexicalEdit) { + EnsureLegacySurfaceInitialized(persistContext); m_avaloniaEntryForm?.Hide(); m_dataEntryForm.Show(); m_dataEntryForm.BringToFront(); diff --git a/Src/xWorks/xWorksTests/BulkEditBarTests.cs b/Src/xWorks/xWorksTests/BulkEditBarTests.cs index 0504a2d540..389144135d 100644 --- a/Src/xWorks/xWorksTests/BulkEditBarTests.cs +++ b/Src/xWorks/xWorksTests/BulkEditBarTests.cs @@ -444,6 +444,19 @@ internal IReadOnlyList GetFilterReachabilityBaseline() ContainsItemType(fsi.Combo))).ToList(); } + internal FwComboBox GetFilterCombo(string columnName) + { + var fsi = FindColumnInfo(columnName); + return fsi != null ? fsi.Combo : null; + } + + internal int GetFilterItemIndex(string columnName) + where TItem : class + { + var fsi = FindColumnInfo(columnName); + return fsi == null ? -1 : FindIndexByItemType(fsi.Combo); + } + private static string GetOptionalAttributeValue(XmlNode spec, string attributeName) { return spec != null && spec.Attributes != null && spec.Attributes[attributeName] != null diff --git a/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs new file mode 100644 index 0000000000..fa4d0c3b75 --- /dev/null +++ b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs @@ -0,0 +1,159 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.IO; +using System.Collections.Generic; +using System.Reflection; +using System.Windows.Forms; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Task 3.10 audit: when the Avalonia surface is active, RecordEditView must not instantiate or + /// drive the hidden legacy DataTree. This proves the active-host contract on the real product + /// host by loading the lexicon edit tool fresh in the New UI mode and asserting the legacy surface was + /// never initialized, while the Avalonia surface was. + /// + [TestFixture] + [Apartment(System.Threading.ApartmentState.STA)] + public class RecordEditViewActiveHostContractTests : XWorksAppTestBase + { + private PropertyTable m_propertyTable; + private List m_createdObjects; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, m_application.DefaultConfigurationPathname); + } + + [SetUp] + public void SetUpWindow() + { + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); + } + + [TearDown] + public void TearDownWindow() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyLexiconTestData); + m_createdObjects = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + { + m_window.Dispose(); + m_window = null; + } + } + + [Test] + public void AvaloniaActive_DoesNotInitializeOrDriveLegacyDataTree() + { + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + + Assert.That(GetPrivateFieldValue(control, "m_lexicalEditSurface"), Is.EqualTo(LexicalEditSurface.Avalonia), + "Precondition: the lexicon edit tool should resolve to the Avalonia surface under the New UI mode."); + + // Active-host contract (task 3.10): the legacy DataTree must not have been initialized or driven + // while Avalonia is the active surface. This is the audited invariant. + Assert.That(GetPrivateFieldValue(control, "m_legacySurfaceInitialized"), Is.EqualTo(false), + "The active Avalonia path must not instantiate or drive the hidden legacy DataTree (task 3.10)."); + + // Note: realizing the Avalonia WinForms-interop host requires a real UI context, which this + // headless xWorks harness does not provide, so we do not assert the host was created here. The + // FwAvaloniaTests headless suite covers Avalonia surface construction/rendering directly. + } + + private void LoadRecordEditView(string toolValue) + { + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + Assert.That(windowConfiguration, Is.Not.Null); + var controlNode = windowConfiguration.SelectSingleNode( + string.Format("//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", toolValue)); + Assert.That(controlNode, Is.Not.Null, "Expected the RecordEditView configuration node for tool '{0}'.", toolValue); + + m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", toolValue, true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + private void CreateLexiconTestData() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var nounPartOfSpeech = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + AddLexeme(m_createdObjects, "contract-entry", stemMorphType, "contract gloss", nounPartOfSpeech); + } + + private void DestroyLexiconTestData() + { + if (m_createdObjects == null) + return; + foreach (var obj in m_createdObjects) + { + if (!obj.IsValidObject) + continue; + if (obj is ILexEntry) + obj.Delete(); + } + } + + private static object GetPrivateFieldValue(object target, string fieldName) + { + var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Missing private field: " + fieldName); + return field.GetValue(target); + } + + private void DrainMediatorAndIdleQueues() + { + var idleQueue = m_window.Mediator.IdleQueue; + var processIdle = idleQueue.GetType().GetMethod("Application_Idle", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(processIdle, Is.Not.Null); + + for (var iteration = 0; iteration < 8; iteration++) + { + ((MockFwXWindow)m_window).ProcessPendingItems(); + if (idleQueue.Count == 0 && m_window.Mediator.JobItems == 0) + break; + if (idleQueue.Count > 0) + processIdle.Invoke(idleQueue, new object[] { this, EventArgs.Empty }); + } + + Application.DoEvents(); + } + + private void EnsureCurrentRecord(RecordEditView control) + { + if (control.Clerk.CurrentObject != null) + return; + control.Clerk.JumpToIndex(0); + DrainMediatorAndIdleQueues(); + Assert.That(control.Clerk.CurrentObject, Is.Not.Null); + } + } +} diff --git a/Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs b/Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs new file mode 100644 index 0000000000..47716a0fbb --- /dev/null +++ b/Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs @@ -0,0 +1,213 @@ +using System; +using System.IO; +using System.Collections.Generic; +using System.Reflection; +using System.Windows.Forms; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + [Apartment(System.Threading.ApartmentState.STA)] + public class RecordEditViewSwitchTests : XWorksAppTestBase + { + private PropertyTable m_propertyTable; + private List m_createdObjects; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, m_application.DefaultConfigurationPathname); + } + + [SetUp] + public void SetUpWindow() + { + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); + } + + [TearDown] + public void TearDownWindow() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyLexiconTestData); + m_createdObjects = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + { + m_window.Dispose(); + m_window = null; + } + } + + [Test] + public void LexiconEditTool_UsesLegacyDataTree_WhenUIModeIsLegacy() + { + m_propertyTable.SetProperty("UIMode", "Legacy", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + Assert.That(control.DatTree, Is.Not.Null); + Assert.That(GetPrivateFieldValue(control, "m_avaloniaEntryForm"), Is.Null); + Assert.That(GetPrivateFieldValue(control, "m_lexicalEditSurface"), Is.EqualTo(LexicalEditSurface.WinForms)); + } + + [Test] + public void LexiconEditTool_SwitchesSurfaceStateToAvalonia_WhenUIModePropertyBroadcasts() + { + m_propertyTable.SetProperty("UIMode", "Legacy", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView(); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + + m_propertyTable.SetProperty("UIMode", "New", true); + DrainMediatorAndIdleQueues(); + + var sameControl = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(sameControl, Is.SameAs(control), "Changing the UI mode should update the live content control rather than requiring a tool reload in the test harness."); + Assert.That(control.Clerk.CurrentObject, Is.Not.Null); + Assert.That(GetPrivateFieldValue(control, "m_lexicalEditSurface"), Is.EqualTo(LexicalEditSurface.Avalonia)); + } + + [TestCase("posEdit")] + [TestCase("notebookEdit")] + [TestCase("domainTypeEdit")] + [TestCase("Analyses")] + public void NonMigratedRecordEditTools_FallBackToLegacy_WhenUIModeIsNew(string toolValue) + { + m_propertyTable.SetProperty("UIMode", "New", true); + m_propertyTable.SetPropertyPersistence("UIMode", false); + + LoadRecordEditView(toolValue); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null, "Expected RecordEditView for tool '{0}'.", toolValue); + Assert.That( + GetPrivateFieldValue(control, "m_lexicalEditSurface"), + Is.EqualTo(LexicalEditSurface.WinForms), + "Tool '{0}' should explicitly fall back to legacy while Avalonia support is not yet implemented.", + toolValue); + } + + private void LoadRecordEditView() + { + LoadRecordEditView("lexiconEdit"); + } + + private void LoadRecordEditView(string toolValue) + { + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + Assert.That(windowConfiguration, Is.Not.Null, "The xWorks test window should load a merged WindowConfiguration before RecordEditView is activated."); + var controlNode = windowConfiguration.SelectSingleNode( + string.Format("//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", toolValue)); + Assert.That(controlNode, Is.Not.Null, "Expected to find the RecordEditView configuration node for tool '{0}'.", toolValue); + + m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", toolValue, true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + private void CreateLexiconTestData() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var nounPartOfSpeech = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + AddLexeme(m_createdObjects, "switch-entry", stemMorphType, "switch gloss", nounPartOfSpeech); + } + + private void DestroyLexiconTestData() + { + if (m_createdObjects == null) + return; + + foreach (var obj in m_createdObjects) + { + if (!obj.IsValidObject) + continue; + if (obj is ILexEntry) + obj.Delete(); + } + } + + private static T GetPrivateField(object target, string fieldName) where T : class + { + var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Missing private field: " + fieldName); + return field.GetValue(target) as T; + } + + private static object GetPrivateFieldValue(object target, string fieldName) + { + var field = target.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null, "Missing private field: " + fieldName); + return field.GetValue(target); + } + + private void DrainMediatorAndIdleQueues() + { + var idleQueue = m_window.Mediator.IdleQueue; + var processIdle = idleQueue.GetType().GetMethod("Application_Idle", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(processIdle, Is.Not.Null, "Expected to access IdleQueue.Application_Idle for xWorks test pumping."); + + for (var iteration = 0; iteration < 8; iteration++) + { + ((MockFwXWindow)m_window).ProcessPendingItems(); + if (idleQueue.Count == 0 && m_window.Mediator.JobItems == 0) + break; + + if (idleQueue.Count > 0) + processIdle.Invoke(idleQueue, new object[] { this, EventArgs.Empty }); + } + + Application.DoEvents(); + } + + private void EnsureCurrentRecord(RecordEditView control) + { + if (control.Clerk.CurrentObject != null) + return; + + control.Clerk.JumpToIndex(0); + DrainMediatorAndIdleQueues(); + Assert.That(control.Clerk.CurrentObject, Is.Not.Null, "Expected the RecordEditView clerk to resolve a current lexical record for the switch test."); + } + + private static Control FindControlRecursive(Control root, string name) + { + if (root == null) + return null; + if (root.Name == name) + return root; + foreach (Control child in root.Controls) + { + var found = FindControlRecursive(child, name); + if (found != null) + return found; + } + return null; + } + } +} diff --git a/Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs b/Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs new file mode 100644 index 0000000000..33d90e9db3 --- /dev/null +++ b/Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Windows.Automation; +using System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.Controls; +using SIL.FieldWorks.Common.Framework.DetailControls; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + [TestFixture] + [Category("UIA")] + [NonParallelizable] + [Apartment(ApartmentState.STA)] + public class MorphTypeLauncherUiaSmokeTests : XWorksAppTestBase + { + private List m_createdObjects; + private ILexEntry m_entry; + + protected override void Init() + { + m_application = new MockFwXApp( + new MockFwManager { Cache = Cache }, + null, + null); + m_configFilePath = Path.Combine( + FwDirectoryFinder.CodeDirectory, + m_application.DefaultConfigurationPathname); + } + + [SetUp] + public void SetUpLauncher() + { + WinFormsUiaTestHelpers.EnsureInteractiveDesktop(); + + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); + } + + [TearDown] + public void TearDownLauncher() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyLexiconTestData); + m_createdObjects = null; + m_entry = null; + + if (m_window != null && !m_window.IsDisposed) + { + m_window.Dispose(); + m_window = null; + } + } + + [Test] + public void MorphTypeLauncher_RealizedWindow_ExposesInvokePatternThroughUIA() + { + using (var host = new MorphTypeLauncherHost(Cache, m_entry.LexemeFormOA)) + { + host.WaitUntilReady(); + + var button = AutomationElement.FromHandle(host.LauncherButtonHandle); + Assert.That(button, Is.Not.Null); + Assert.That(button.Current.ControlType, Is.EqualTo(ControlType.Button)); + Assert.That( + button.GetCurrentPattern(InvokePattern.Pattern), + Is.Not.Null, + "Launcher button should expose InvokePattern through UIA."); + } + } + + [Test] + public void MorphTypeChooser_RealizedWindow_ExposesCoreTreeAndCancelButtonThroughUIA() + { + using (var host = new MorphTypeChooserHost(Cache, m_entry.LexemeFormOA)) + { + host.WaitUntilReady(); + + var chooser = AutomationElement.FromHandle(host.WindowHandle); + Assert.That(chooser, Is.Not.Null); + Assert.That( + WinFormsUiaTestHelpers.FindByAutomationId(chooser, "m_labelsTreeView"), + Is.Not.Null, + "Chooser tree should be reachable through UIA."); + WinFormsUiaTestHelpers.AssertButtonSupportsInvokePattern( + chooser, + "btnCancel"); + } + } + + private void CreateLexiconTestData() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var nounPartOfSpeech = GetGrammaticalCategoryOrCreateOne( + "noun", + Cache.LangProject.PartsOfSpeechOA); + m_entry = AddLexeme( + m_createdObjects, + "uia-entry", + stemMorphType, + "uia gloss", + nounPartOfSpeech); + } + + private void DestroyLexiconTestData() + { + if (m_createdObjects == null) + return; + + foreach (var obj in m_createdObjects) + { + if (!obj.IsValidObject) + continue; + if (obj is ILexEntry) + obj.Delete(); + } + } + + private sealed class MorphTypeLauncherHost : IDisposable + { + private readonly LcmCache m_cache; + private readonly ICmObject m_obj; + private readonly ManualResetEventSlim m_ready = new ManualResetEventSlim(); + private readonly Thread m_thread; + private Exception m_startupException; + private Form m_hostForm; + private Mediator m_mediator; + private PropertyTable m_propertyTable; + private MorphTypeAtomicLauncher m_launcher; + + public MorphTypeLauncherHost(LcmCache cache, ICmObject obj) + { + m_cache = cache; + m_obj = obj; + m_thread = new Thread(ThreadMain) { IsBackground = true }; + m_thread.SetApartmentState(ApartmentState.STA); + m_thread.Start(); + } + + public IntPtr LauncherButtonHandle { get; private set; } + + public void WaitUntilReady() + { + Assert.That( + m_ready.Wait(TimeSpan.FromSeconds(10)), + Is.True, + "Timed out waiting for the launcher host window."); + if (m_startupException != null) + { + Assert.Fail( + "Morph type launcher host failed to start: " + m_startupException); + } + } + + public void Dispose() + { + try + { + if (m_hostForm != null && !m_hostForm.IsDisposed && m_hostForm.IsHandleCreated) + { + m_hostForm.BeginInvoke(new MethodInvoker(() => m_hostForm.Close())); + } + } + catch + { + } + + if (!m_thread.Join(TimeSpan.FromSeconds(5))) + m_thread.Abort(); + + m_propertyTable?.Dispose(); + m_propertyTable = null; + m_mediator?.Dispose(); + m_mediator = null; + m_ready.Dispose(); + } + + private void ThreadMain() + { + try + { + m_mediator = new Mediator(); + m_propertyTable = new PropertyTable(m_mediator); + m_hostForm = new Form + { + Text = "MorphTypeLauncherUiaHost", + ShowInTaskbar = false, + StartPosition = FormStartPosition.Manual, + Left = 80, + Top = 80, + Width = 320, + Height = 120 + }; + m_propertyTable.SetProperty("window", m_hostForm, false); + m_propertyTable.SetPropertyPersistence("window", false); + + m_launcher = new MorphTypeAtomicLauncher { Dock = DockStyle.Fill }; + m_launcher.Initialize( + m_cache, + m_obj, + MoFormTags.kflidMorphType, + "MorphTypeRA", + null, + m_mediator, + m_propertyTable, + string.Empty, + "analysis"); + m_hostForm.Controls.Add(m_launcher); + m_hostForm.Show(); + m_hostForm.Activate(); + Application.DoEvents(); + + LauncherButtonHandle = m_launcher.LauncherButton.Handle; + m_ready.Set(); + Application.Run(m_hostForm); + } + catch (Exception ex) + { + m_startupException = ex; + m_ready.Set(); + } + } + } + + private sealed class MorphTypeChooserHost : IDisposable + { + private readonly LcmCache m_cache; + private readonly ICmObject m_obj; + private readonly ManualResetEventSlim m_ready = new ManualResetEventSlim(); + private readonly Thread m_thread; + private Exception m_startupException; + private MorphTypeChooser m_chooser; + + public MorphTypeChooserHost(LcmCache cache, ICmObject obj) + { + m_cache = cache; + m_obj = obj; + m_thread = new Thread(ThreadMain) { IsBackground = true }; + m_thread.SetApartmentState(ApartmentState.STA); + m_thread.Start(); + } + + public IntPtr WindowHandle { get; private set; } + + public void WaitUntilReady() + { + Assert.That( + m_ready.Wait(TimeSpan.FromSeconds(10)), + Is.True, + "Timed out waiting for the chooser host window."); + if (m_startupException != null) + { + Assert.Fail("Morph type chooser host failed to start: " + m_startupException); + } + } + + public void Dispose() + { + try + { + if (m_chooser != null && !m_chooser.IsDisposed && m_chooser.IsHandleCreated) + { + m_chooser.BeginInvoke(new MethodInvoker(() => m_chooser.Close())); + } + } + catch + { + } + + if (!m_thread.Join(TimeSpan.FromSeconds(5))) + m_thread.Abort(); + + m_ready.Dispose(); + } + + private void ThreadMain() + { + try + { + var labels = ObjectLabel.CreateObjectLabels( + m_cache, + m_obj.ReferenceTargetCandidates(MoFormTags.kflidMorphType), + string.Empty, + "analysis vernacular"); + m_chooser = new MorphTypeChooser(null, labels, "MorphTypeRA", null); + m_chooser.Show(); + m_chooser.Activate(); + Application.DoEvents(); + WindowHandle = m_chooser.Handle; + m_ready.Set(); + Application.Run(m_chooser); + } + catch (Exception ex) + { + m_startupException = ex; + m_ready.Set(); + } + } + } + } + + [TestFixture] + [Category("UIA")] + [NonParallelizable] + [Apartment(ApartmentState.STA)] + public class BulkEditBarUiaSmokeTests : BulkEditBarTestsBase + { + [Test] + public void FilterBar_RealizedWindow_ExposesTargetCombosThroughUIA() + { + WinFormsUiaTestHelpers.EnsureInteractiveDesktop(); + + m_window.Show(); + m_window.Activate(); + Application.DoEvents(); + + var window = AutomationElement.FromHandle(m_window.Handle); + Assert.That(window, Is.Not.Null); + + var filterBar = WinFormsUiaTestHelpers.FindByAutomationId(window, "FilterBar"); + Assert.That(filterBar, Is.Not.Null, "FilterBar should be reachable through UIA."); + + var lexemeCombo = WinFormsUiaTestHelpers.FindByAutomationId( + window, + "FilterCombo.LexemeFormForEntry"); + Assert.That( + lexemeCombo, + Is.Not.Null, + "Lexeme Form filter combo should be reachable through UIA."); + + var morphTypeCombo = WinFormsUiaTestHelpers.FindByAutomationId( + window, + "FilterCombo.MorphTypeForEntry"); + Assert.That( + morphTypeCombo, + Is.Not.Null, + "Morph Type filter combo should be reachable through UIA."); + + Assert.DoesNotThrow( + () => lexemeCombo.SetFocus(), + "Lexeme Form filter combo should be focusable through UIA."); + Assert.DoesNotThrow( + () => morphTypeCombo.SetFocus(), + "Morph Type filter combo should be focusable through UIA."); + } + } + + internal static class WinFormsUiaTestHelpers + { + internal static void EnsureInteractiveDesktop() + { + if (!Environment.UserInteractive) + { + Assert.Ignore( + "UIA2 WinForms smoke tests require an interactive Windows desktop/session."); + } + } + + internal static AutomationElement FindByAutomationId( + AutomationElement root, + string automationId) + { + return root.FindFirst( + TreeScope.Descendants, + new PropertyCondition(AutomationElement.AutomationIdProperty, automationId)); + } + + internal static void AssertButtonSupportsInvokePattern( + AutomationElement dialog, + string buttonAutomationId) + { + var button = FindByAutomationId(dialog, buttonAutomationId); + Assert.That(button, Is.Not.Null, "Expected to find button '{0}'.", buttonAutomationId); + var invoke = button.GetCurrentPattern(InvokePattern.Pattern) as InvokePattern; + Assert.That(invoke, Is.Not.Null, "Button '{0}' should support InvokePattern.", buttonAutomationId); + } + } +} \ No newline at end of file diff --git a/Src/xWorks/xWorksTests/xWorksTests.csproj b/Src/xWorks/xWorksTests/xWorksTests.csproj index cd017d24cd..c67c769b37 100644 --- a/Src/xWorks/xWorksTests/xWorksTests.csproj +++ b/Src/xWorks/xWorksTests/xWorksTests.csproj @@ -45,6 +45,8 @@ + + diff --git a/build.ps1 b/build.ps1 index 122b24a635..4eae965a6f 100644 --- a/build.ps1 +++ b/build.ps1 @@ -32,12 +32,6 @@ If set, includes optional utility applications (e.g. MigrateSqlDbs, LCMBrowser, UnicodeCharEditor) in the build. Default is false unless -BuildInstaller is specified, which enables it automatically. -.PARAMETER BuildAvalonia - If set, builds optional Avalonia projects that are present on the current branch after the main - FieldWorks build completes. This is preview-only by default and does not change the traversal build. - On this branch it builds the isolated `Src/Common/FwAvalonia/` projects; if a preview host and/or - feature module projects (for example `*.Avalonia`) are present, they are built too. - .PARAMETER Verbosity Specifies the amount of information to display in the build log. Values: q[uiet], m[inimal], n[ormal], d[etailed], diag[nostic]. @@ -157,10 +151,6 @@ .\build.ps1 -UseLocalLcm Builds FieldWorks, then builds liblcm from ../liblcm and copies DLLs into Output. -.EXAMPLE - .\build.ps1 -BuildAvalonia - Builds FieldWorks, then builds any Avalonia preview/module projects present on the current branch. - .NOTES FieldWorks is x64-only. The x86 platform is no longer supported. #> @@ -174,7 +164,6 @@ param( [switch]$RunTests, [string]$TestFilter, [switch]$BuildAdditionalApps, - [switch]$BuildAvalonia, [string]$Project = "FieldWorks.proj", [string]$Verbosity = "minimal", [ValidateSet('true', 'false', 'auto')] @@ -853,34 +842,32 @@ try { Write-Host "[OK] Build complete!" -ForegroundColor Green Write-Host "Output: Output\$Configuration" -ForegroundColor Cyan - if ($BuildAvalonia) { - Write-Host "" - Write-Host "Building net48-compatible Avalonia projects present on this branch..." -ForegroundColor Cyan + Write-Host "" + Write-Host "Building net48-compatible Avalonia projects present on this branch..." -ForegroundColor Cyan - $avaloniaProjects = Get-AvaloniaProjectList -RepoRoot $PSScriptRoot -IncludeTests ($BuildTests -or $RunTests) - if ($avaloniaProjects.Count -gt 0 -and (Test-Path $projectPath)) { - $normalizedMainProjectPath = (Resolve-Path $projectPath).Path - $avaloniaProjects = @($avaloniaProjects | Where-Object { (Resolve-Path $_).Path -ne $normalizedMainProjectPath }) - } - if ($avaloniaProjects.Count -eq 0) { - Write-Host "No additional net48-compatible Avalonia projects were found on this branch." -ForegroundColor Yellow - } - else { - foreach ($avaloniaProject in $avaloniaProjects) { - Invoke-MSBuild ` - -Arguments @($avaloniaProject, '/t:Restore;Build', "/p:Configuration=$Configuration", "/p:Platform=$Platform", '/v:minimal', '/nologo') ` - -Description ("Avalonia project: {0}" -f [System.IO.Path]::GetFileNameWithoutExtension($avaloniaProject)) - } + $avaloniaProjects = Get-AvaloniaProjectList -RepoRoot $PSScriptRoot -IncludeTests ($BuildTests -or $RunTests) + if ($avaloniaProjects.Count -gt 0 -and (Test-Path $projectPath)) { + $normalizedMainProjectPath = (Resolve-Path $projectPath).Path + $avaloniaProjects = @($avaloniaProjects | Where-Object { (Resolve-Path $_).Path -ne $normalizedMainProjectPath }) + } + if ($avaloniaProjects.Count -eq 0) { + Write-Host "No additional net48-compatible Avalonia projects were found on this branch." -ForegroundColor Yellow + } + else { + foreach ($avaloniaProject in $avaloniaProjects) { + Invoke-MSBuild ` + -Arguments @($avaloniaProject, '/t:Restore;Build', "/p:Configuration=$Configuration", "/p:Platform=$Platform", '/v:minimal', '/nologo') ` + -Description ("Avalonia project: {0}" -f [System.IO.Path]::GetFileNameWithoutExtension($avaloniaProject)) } + } - $previewHostPath = Join-Path $PSScriptRoot "Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj" - if (-not (Test-Path $previewHostPath)) { - Write-Host "Avalonia preview host is not present on this branch." -ForegroundColor Yellow - Write-Host "The existing host lives on '010-advanced-entry-preview-prototype' and is net8-based, so it is outside this branch's net48-only policy." -ForegroundColor Yellow - } - elseif (-not (Test-IsNet48CompatibleProject -ProjectPath $previewHostPath)) { - Write-Host "Avalonia preview host exists but is not net48-compatible, so -BuildAvalonia skipped it." -ForegroundColor Yellow - } + $previewHostPath = Join-Path $PSScriptRoot "Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHost.csproj" + if (-not (Test-Path $previewHostPath)) { + Write-Host "Avalonia preview host is not present on this branch." -ForegroundColor Yellow + Write-Host "The existing host lives on '010-advanced-entry-preview-prototype' and is net8-based, so it is outside this branch's net48-only policy." -ForegroundColor Yellow + } + elseif (-not (Test-IsNet48CompatibleProject -ProjectPath $previewHostPath)) { + Write-Host "Avalonia preview host exists but is not net48-compatible, so it was skipped." -ForegroundColor Yellow } } diff --git a/openspec/changes/avalonia-migration-roadmap/design.md b/openspec/changes/avalonia-migration-roadmap/design.md index d390a56372..27d7b2b2c9 100644 --- a/openspec/changes/avalonia-migration-roadmap/design.md +++ b/openspec/changes/avalonia-migration-roadmap/design.md @@ -1,21 +1,32 @@ ## Context -The recommendation from `Docs/avalonia-migration-approach-comparison.md` is **Approach 3 then -Approach 2**: run a time-boxed proof-of-concept spike, then execute the Hybrid (the lexical-edit -program as the spine, with the DataTree model/view split as the first concrete migrated region). This -roadmap encodes that recommendation as an ordered, gated sequence so the work is minimal-risk and the -two older plans stop competing. - -Two plans are reconciled: - -- **Plan A — `datatree-model-view-separation`**: splits the 4.7k-line `DataTree.cs` into a UI-agnostic - `DataTreeModel` + `SliceSpec[]` + `IDataTreeView`. Low risk, days of work, but stops at the - abstraction boundary (no Avalonia, no flag, no Graphite/native work). -- **Plan B — `lexical-edit-avalonia-migration` (+ `fieldworks-avalonia-shell-migration`)**: the - end-to-end program with typed view definitions, seams, parity automation, Graphite/native - decommissioning, the two-adapter flag, and the shell. - -The Hybrid uses B as the spine and runs A as B's first migrated region. +> **Status note (2026-06-09 — resolved).** Execution diverged from the original sequence. After +> Gate 0, work proceeded directly into the lexical-edit program (sections 2–4 of +> `lexical-edit-avalonia-migration/tasks.md`) and built the region-model path +> (`ViewDefinitionModel`/`LexicalEditRegionModel` through `RecordEditView`) rather than Plan A's +> `DataTreeModel`/`SliceSpec`/`IDataTreeView`. `seam-domain-comparison.md` classifies wiring new +> ports into legacy `DataTree` internals as throwaway work, which undercuts Plan A's premise. +> +> **Resolution (1.13 done 2026-06-09):** `datatree-model-view-separation` is formally superseded as +> a migration gate. `DataTree` is frozen on the legacy side and will be deleted at end of the ~1-year +> coexistence phase; its internal extraction (`DataTreeModel`/`SliceSpec`/`IDataTreeView`) is +> optional legacy maintenance only. Gate 1 is redefined below around the region-model boundary. +> See `datatree-model-view-separation/hybrid-alignment.md` for the superseded banner and historical +> content. + +The recommendation from `Docs/avalonia-migration-approach-comparison.md` was **Approach 3 then +Approach 2**: a time-boxed proof-of-concept spike, then the Hybrid (Plan B as the spine). Execution +confirmed that approach but resolved the Phase 1 boundary differently from the original plan: instead +of extracting a model layer from `DataTree`, Phase 1 built a typed IR path (`ViewDefinitionModel` → +`LexicalEditRegionModel`) that bypasses `DataTree` entirely on the Avalonia side. + +**Plan A — `datatree-model-view-separation`** (superseded as migration gate): would have split +`DataTree.cs` into `DataTreeModel`/`SliceSpec`/`IDataTreeView`. Refactoring the internals of a class +that will be deleted in ~1 year is throwaway. DataTree stays frozen as the legacy surface. + +**Plan B — `lexical-edit-avalonia-migration` (+ `fieldworks-avalonia-shell-migration`)**: the +end-to-end program. This is the active plan. Phase 1 was executed as sections 3–4 of the +lexical-edit tasks. ## Goals / Non-Goals @@ -31,25 +42,30 @@ The Hybrid uses B as the spine and runs A as B's first migrated region. ## Decisions -### 1. Sequence: POC → DataTree region → Lexical Edit → Shell +### 1. Sequence: POC → first migrated region → Lexical Edit → Shell -**Decision:** Phase 0 is the POC spike; Phase 1 is the DataTree model/view split executed as a -migrated region; Phases 2–6 are the lexical-edit program; Phase 7+ is the shell, gated on the -regional gates. +**Decision:** Phase 0 is the POC spike; Phase 1 is the first migrated region via the region-model +path (lexical-edit-avalonia-migration sections 3–4); Phases 2–6 are the continued lexical-edit +program; Phase 7+ is the shell, gated on the regional gates. -**Rationale:** This banks the cheapest risk reduction first (POC), then the densest, highest-value -real screen, and defers the most expensive work (shell) until the regional pattern is proven — which -is the dependency the lexical-edit program already mandates. +**Rationale:** Banks cheapest risk reduction first (POC), then the typed-IR + surface-seam +foundation that all further Avalonia screens build on, and defers the most expensive work (shell) +until the regional pattern is proven. The `DataTree` internal extraction was originally planned as +Phase 1 but is superseded: bypassing DataTree entirely on the Avalonia path is both simpler and +avoids investing in a class that will be deleted. -### 2. Overlap resolution: A is a concrete realization of B +### 2. Region-model boundary as the seam -**Decision:** `SliceSpec` (Plan A) is a concrete instance of the typed view-definition node (Plan B); -`IDataTreeView` (Plan A) is one of the two adapters selected by the two-adapter flag (Plan B). The -DataTree region's `AvaloniaDataTreeView` consumes the same `DataTreeModel`/`SliceSpec` the WinForms -view uses and is selected by the flag. +**Decision:** The boundary between legacy and Avalonia is `ViewDefinitionModel` (typed IR compiled +from XML layouts) + `LexicalEditRegionModel` (value-bound region) + `IRegionValueProvider` (seam to +LCModel). `RecordEditView` selects the surface via `LexicalEditSurfaceSelectionService`; the +active-host contract (`ActiveHostContract`) forbids driving hidden legacy `DataTree` infrastructure +when Avalonia is active. `DataTree` remains the complete legacy surface — no internal extraction. -**Rationale:** Avoids building two competing boundary types. The DataTree split produces the swap -point; the lexical-edit program supplies the flag, parity harness, and Graphite/native gates. +**Rationale:** Avalonia does not need to understand DataTree's mental model (slices, XML configs, +ObjSeqHashMap reuse keys). The typed IR path is standalone, testable without WinForms, and can be +compiled off-thread. DataTree is deleted wholesale at end of the coexistence phase; the seam is at +RecordEditView routing, not inside DataTree. ### 3. Minimal-risk posture throughout @@ -65,9 +81,9 @@ flowchart TB direction LR A0["Flag + in-proc host bridge
one slice (3 editors)
density/parity evidence"]:::poc end - subgraph P1["Phase 1 — DataTree region (datatree-model-view-separation)"] + subgraph P1["Phase 1 — First migrated region (lexical-edit-avalonia-migration §3–4)"] direction LR - A1["Char. tests → partial split →
extract collaborators →
DataTreeModel + SliceSpec + IDataTreeView"]:::region + A1["Seams → typed IR (ViewDefinitionModel) →
LexicalEditRegionModel + IRegionValueProvider →
RecordEditView routing + active-host contract"]:::region end subgraph P2["Phases 2–6 — Lexical Edit program (lexical-edit-avalonia-migration)"] direction LR @@ -79,7 +95,7 @@ flowchart TB end G0{"Gate 0
host bridge proven +
density acceptable +
flag dual-run works"}:::gate - G1{"Gate 1
AvaloniaDataTreeView at
semantic+density parity,
behind flag, no native/Graphite"}:::gate + G1{"Gate 1
LexicalEditRegionView at semantic parity,
DataTree untouched on legacy path,
active-host contract proven, no native/Graphite"}:::gate G2{"Gate 2
Lexical Edit region complete:
parity, native audit clean,
Graphite-free default"}:::gate P0 --> G0 --> P1 --> G1 --> P2 --> G2 --> P7 @@ -96,23 +112,73 @@ flowchart TB - **Gate 0 (POC → region):** in-process net48 host bridge proven (or fallback recorded); density delta acceptable at 100% and 150% DPI; the same build runs either surface behind the flag; `spike-evidence.md` gives go. -- **Gate 1 (region → program):** `AvaloniaDataTreeView` implements `IDataTreeView`, consumes the same - `DataTreeModel`/`SliceSpec` as WinForms, is selected by the two-adapter flag, matches the semantic + - density baseline within tolerance, and instantiates no native Views or Graphite at runtime. +- **Gate 1 (first region → continued program):** `LexicalEditRegionView` renders + `LexicalEditRegionModel` built from `ViewDefinitionModel` + `IRegionValueProvider`; the legacy + `DataTree` is untouched on the legacy path; `RecordEditView` routing selects the appropriate + surface via `LexicalEditSurfaceSelectionService`; `RecordEditViewActiveHostContractTests` proves no + hidden DataTree drive under Avalonia mode; semantic + density baseline matches within tolerance; no + native Views or Graphite on the Avalonia path. **(Passed — lexical-edit-avalonia-migration §3–4 + complete as of 2026-06-09.)** - **Gate 2 (program → shell):** the Lexical Edit region manifest passes — semantic parity, UIA2 legacy baselines, Avalonia.Headless tests, render-comparison evidence, native-viewing audit clean, and no unapproved Graphite/native-rendering default-path dependency. -## Overlap map (vocabulary reconciliation) +## Vocabulary — as-built (Phase 1) + +The original overlap map mapped Plan A vocabulary to Plan B vocabulary. That mapping is superseded. +The vocabulary actually built: ```mermaid -flowchart LR - SS["SliceSpec (Plan A)"]:::a -->|is a concrete| IRN["Typed view-definition node (Plan B)"]:::b - IDV["IDataTreeView (Plan A)"]:::a -->|is one adapter of| FLAG["Two-adapter flag (Plan B)"]:::b - DM["DataTreeModel (Plan A)"]:::a -->|feeds| ER["ILexicalEditorRegistry (Plan B)"]:::b - CT["Characterization tests (Plan A Phase 0)"]:::a -->|extend| PA["Parity automation (Plan B)"]:::b - classDef a fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; - classDef b fill:#dcfce7,stroke:#16a34a,color:#052e16; +flowchart TD + subgraph IR["Typed IR (ViewDefinition)"] + VDM["ViewDefinitionModel\n(layout compile output)"] + VN["ViewNode\n(IR node: field, kind, ws,\nstableId, automationId,\nSurfaceRouting)"] + XI["XmlLayoutImporter\n(XML layouts → IR)"] + VDM --> VN + XI -->|produces| VDM + end + + subgraph Region["Region model (FwAvalonia)"] + LRM["LexicalEditRegionModel\n(value-bound fields)"] + LRMAP["LexicalEditRegionMapper\n(IR + values → region)"] + IRVP["IRegionValueProvider\n(seam: LCModel-free)"] + LRV["LexicalEditRegionView\n(data-driven Avalonia UI)"] + LRMAP -->|projects| LRM + IRVP -->|supplies values to| LRMAP + LRM -->|rendered by| LRV + end + + subgraph Seam["Surface seam (FwAvalonia + xWorks)"] + LESS["LexicalEditSurfaceSelectionService\n(UIMode → SurfaceDecision)"] + AHC["ActiveHostContract\n(forbids hidden DataTree drive)"] + REV["RecordEditView\n(routes to legacy or Avalonia surface)"] + LESS -->|informs| REV + AHC -->|enforced by| REV + end + + subgraph LCModel["LCModel boundary (xWorks)"] + LRB["LexicalEditRegionBuilder\n(IRegionValueProvider impl;\nreads ILexEntry)"] + end + + subgraph Legacy["Legacy (frozen)"] + DT["DataTree\n(unchanged WinForms surface;\ndeleted at end of coexist phase)"] + end + + VN -->|walked by| LRMAP + LRB -->|implements| IRVP + REV -->|Avalonia path: calls| LRB + REV -->|Legacy path: calls| DT + + classDef ir fill:#fef9c3,stroke:#ca8a04,color:#422006; + classDef region fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef seam fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef lcm fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef legacy fill:#f1f5f9,stroke:#94a3b8,color:#475569; + class VDM,VN,XI ir; + class LRM,LRMAP,IRVP,LRV region; + class LESS,AHC,REV seam; + class LRB lcm; + class DT legacy; ``` ## Risk controls diff --git a/openspec/changes/avalonia-migration-roadmap/proposal.md b/openspec/changes/avalonia-migration-roadmap/proposal.md index 98ca21712c..e01d5ad48b 100644 --- a/openspec/changes/avalonia-migration-roadmap/proposal.md +++ b/openspec/changes/avalonia-migration-roadmap/proposal.md @@ -1,5 +1,12 @@ ## Why +> **Status note (2026-06-09).** The original proposal planned `datatree-model-view-separation` as +> Phase 1. Execution diverged: Phase 1 was built directly as the region-model path +> (`ViewDefinitionModel`/`LexicalEditRegionModel`) inside `lexical-edit-avalonia-migration`, bypassing +> DataTree internals entirely. Gate 1 has passed. `datatree-model-view-separation` is formally +> superseded as a migration gate (task 1.13 done 2026-06-09). The active vocabulary and gate +> definitions are in `design.md`; this proposal is preserved as the historical rationale. + Two planning sets exist for moving FieldWorks to Avalonia: an older DataTree model/view separation (`datatree-model-view-separation`) and a newer end-to-end program (`lexical-edit-avalonia-migration` + `fieldworks-avalonia-shell-migration`). They overlap and need a @@ -9,23 +16,20 @@ preserving **functional fidelity and density** (not pixel-perfect), with the new **feature flag** so the same build runs either Avalonia or the legacy WinForms controls. This change is the **umbrella roadmap**. It does not introduce code. It sequences the existing -changes into one minimal-risk path, defines the gates between them, and resolves the overlap between -the two plans (the DataTree split becomes the concrete first migrated region inside the lexical-edit -program). - -## What Changes - -- Adopt the **Hybrid** approach: the lexical-edit program is the spine (typed view definition, - seams, parity automation, Graphite/native decommissioning, two-adapter flag, then the shell); the - DataTree model/view split is executed as the **first concrete migrated region** inside it. -- Add a **proof-of-concept spike** (`lexical-edit-avalonia-poc-spike`) as the entry point, before the - regional migration, to de-risk the host bridge, fidelity/density, and the dual-run flag. -- Define the **ordered sequence and gates** across all changes so no phase starts before its - predecessor's evidence exists. -- Reconcile vocabulary between the two plans: Plan A's `SliceSpec` is a concrete realization of Plan - B's typed view-definition node; Plan A's `IDataTreeView` is selected by Plan B's two-adapter flag. -- Keep the comparison analysis (`Docs/avalonia-migration-approach-comparison.md`) as the rationale of - record for choosing the Hybrid. +changes into one minimal-risk path and defines the gates between them. + +## What Changed (as-built) + +- **Phase 0 (POC spike):** completed. In-process host bridge proven; product wiring evidence + exists. See `lexical-edit-avalonia-poc-spike/spike-evidence.md`. +- **Phase 1 (first migrated region):** completed via `lexical-edit-avalonia-migration` sections 3–4. + The boundary is the region-model path (`ViewDefinitionModel` → `LexicalEditRegionModel`), not the + originally-planned DataTree extraction. `DataTree` is frozen on the legacy side. Gate 1 passed. + See `avalonia-migration-roadmap/design.md` for the updated Gate 1 definition and vocabulary. +- **`datatree-model-view-separation`** is superseded as a migration gate. Optional legacy maintenance + only; does not gate Avalonia feature work. +- **Phases 2–6** (continued `lexical-edit-avalonia-migration`): in progress. +- **Phase 7+** (shell): deferred until regional gates are proven. ## Non-goals @@ -38,14 +42,15 @@ program). ### New Capabilities -- `avalonia-migration-roadmap`: The ordered, gated sequence and overlap resolution that governs how - the proof-of-concept, DataTree region split, lexical-edit migration, and shell migration proceed. +- `avalonia-migration-roadmap`: The ordered, gated sequence that governs how the proof-of-concept, + first migrated region, lexical-edit migration, and shell migration proceed. ## Referenced changes (not duplicated here) -- `lexical-edit-avalonia-poc-spike` — the entry-point proof of concept (this roadmap's Phase 0). -- `datatree-model-view-separation` — the first concrete migrated region (this roadmap's Phase 1). -- `lexical-edit-avalonia-migration` — the regional program spine (Phases 2–6). +- `lexical-edit-avalonia-poc-spike` — the entry-point proof of concept (Phase 0, complete). +- `lexical-edit-avalonia-migration` — the regional program spine (Phase 1 complete, Phases 2–6 in progress). +- `datatree-model-view-separation` — **superseded as Phase 1**; optional legacy maintenance only. + See `datatree-model-view-separation/hybrid-alignment.md`. - `fieldworks-avalonia-shell-migration` — the application-wide shell migration (Phase 7+), gated. - `detail-controls-testability`, `retire-linux-era-view-shims`, `render-speedup-benchmark` — supporting/companion work that reduces risk but does not gate the main sequence. diff --git a/openspec/changes/datatree-model-view-separation/hybrid-alignment.md b/openspec/changes/datatree-model-view-separation/hybrid-alignment.md index 3db59a3cf3..b261d4e9a2 100644 --- a/openspec/changes/datatree-model-view-separation/hybrid-alignment.md +++ b/openspec/changes/datatree-model-view-separation/hybrid-alignment.md @@ -1,9 +1,39 @@ # Hybrid Alignment: DataTree split as the first migrated region -This change (`datatree-model-view-separation`) is sequenced by `avalonia-migration-roadmap` as +> **Superseded (2026-06-09 — task 1.13).** This alignment document was written when the roadmap +> planned `datatree-model-view-separation` as Phase 1 of the Lexical Edit migration. Execution +> diverged: Phase 1 was built directly as the region-model path (`ViewDefinitionModel` → +> `LexicalEditRegionModel`) inside `lexical-edit-avalonia-migration`, bypassing `DataTree` internals +> entirely on the Avalonia side. +> +> **Why the original plan is superseded:** Refactoring the internals of `DataTree` to extract +> `DataTreeModel`/`SliceSpec`/`IDataTreeView` invests in a class that will be deleted when the +> ~1-year coexistence phase ends and WinForms is removed. `seam-domain-comparison.md` classifies +> wiring new ports into legacy `DataTree` internals as throwaway work. Avalonia does not need to +> understand DataTree's mental model; `ViewDefinitionModel` is the typed IR that XML layouts compile +> to, and `LexicalEditRegionModel` is the Avalonia-native binding model. +> +> **Current status of DataTree:** `DataTree` is frozen as the complete legacy WinForms surface. The +> seam is at `RecordEditView` routing — when Avalonia is active, `DataTree` is not invoked at all +> (enforced by `ActiveHostContract` and audited by `RecordEditViewActiveHostContractTests`). DataTree +> will be deleted wholesale at end of the coexistence phase. +> +> **What the DataTree refactoring work is good for:** Partial-class split and characterization tests +> remain valid as **optional legacy maintenance** — they reduce complexity while DataTree is still +> alive. But they do not gate any Avalonia feature work. `DataTreeModel`/`SliceSpec`/`IDataTreeView` +> should not be built. +> +> **Active vocabulary and Gate 1 definition:** see `avalonia-migration-roadmap/design.md`. +> +> *(Original content preserved below for historical reference.)* + +--- + +This change (`datatree-model-view-separation`) was sequenced by `avalonia-migration-roadmap` as **Phase 1 — the first concrete migrated region** of the Lexical Edit Avalonia program -(`lexical-edit-avalonia-migration`). It is no longer a standalone end state that "stops at the -abstraction boundary"; it is the swap point that the program's two-adapter feature flag selects. +(`lexical-edit-avalonia-migration`). It was no longer a standalone end state that "stops at the +abstraction boundary"; it was meant to be the swap point that the program's two-adapter feature flag +selects. This framing is superseded; see the note above. ## What changes about this plan's framing diff --git a/openspec/changes/datatree-model-view-separation/proposal.md b/openspec/changes/datatree-model-view-separation/proposal.md index d0bdb22cdf..bd9d391936 100644 --- a/openspec/changes/datatree-model-view-separation/proposal.md +++ b/openspec/changes/datatree-model-view-separation/proposal.md @@ -1,5 +1,17 @@ ## Why +> **Status note (2026-06-09 — superseded as migration gate).** This change was originally planned +> as Phase 1 of the Avalonia migration (the first concrete migrated region). That plan is superseded: +> the migration boundary is the region-model path (`ViewDefinitionModel`/`LexicalEditRegionModel`), +> not a `DataTree` internal extraction. `DataTree` is frozen on the legacy side and will be deleted +> at end of the ~1-year coexistence phase. Extracting `DataTreeModel`/`SliceSpec`/`IDataTreeView` +> would invest in a class being deleted. See `datatree-model-view-separation/hybrid-alignment.md` +> and `avalonia-migration-roadmap/design.md` for the full context. +> +> **What remains valid:** Partial-class split and additional characterization test coverage are +> worthwhile as optional legacy maintenance while `DataTree` is still alive. `DataTreeModel`, +> `SliceSpec`, and `IDataTreeView` should not be built. + DataTree.cs (currently ~4.7k lines on this branch) is a God Class that fuses XML layout parsing, slice lifecycle management, WinForms layout/paint, focus navigation, mediator messaging, data-change notification, and persistence into a single `UserControl`. This makes it difficult to reason about in isolation and blocks straightforward reuse when the project migrates from WinForms to Avalonia. The same problem extends to Slice.cs. With the Avalonia migration on the roadmap, we need a UI-framework-agnostic model layer so both WinForms and Avalonia views can coexist during the transition period. ### Current implementation snapshot (2026-02-28) diff --git a/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md index e8ee325d75..0d42dfa2ad 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md +++ b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md @@ -367,4 +367,87 @@ flowchart TB classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; classDef decom fill:#fee2e2,stroke:#b91c1c,stroke-width:2px,stroke-dasharray: 6 4,color:#450a0a; -``` \ No newline at end of file +``` + +## 7. Current Branch State Before Convergence: Three Disconnected Tracks + +The earlier `010-advanced-entry-view-phase-1-2` state had a clean seam layer and a clean typed IR that +were each built to "not change behavior" — so neither was wired into the live app — while the only +end-to-end rendering path (the POC) bypassed both and used a hand-written lossy DTO. This is why +Sections 3 and 4 would not "finish cleanly": the tasks sit on the seams *between* tracks. (Section 4.8 +and the active-host contract in 3.10 close the worst of these gaps.) + +```mermaid +flowchart TB + subgraph Legacy["Legacy WinForms — the real product"] + REV["RecordEditView"]:::legacy + DT["DataTree + SliceFactory + launchers"]:::legacy + end + subgraph A["Track A: Clean Seams"] + Ports["8 port interfaces
5 implemented, 2 contract-only, 1 POC stub"]:::port + end + subgraph B["Track B: Typed IR"] + IR["ViewDefinitionModel
compiled from XML, cached, tested"]:::model + end + subgraph C["Track C: Working POC"] + DTO["PocEntryDto
hand-written, 3 fields, lossy"]:::poc + Slice["PocLexEntrySlice + WinFormsAvaloniaControlHost"]:::poc + end + REV -->|reads UIMode| DTO + REV -->|drove a HIDDEN live| DT + DTO --> Slice + A -. not wired to anything .-> Legacy + B -. consumed by nobody .-> C + C -. ignored A and B .-> A + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef poc fill:#dcfce7,stroke:#16a34a,color:#052e16; +``` + +## 8. Convergence Target (Path 3) and Coexistence Cooperation + +The clean seam is the **surface-selection boundary** plus the **typed IR as the data contract** — not +legacy re-plumbed through every port. Legacy stays frozen behind the switch until cutover. During the +~1-year coexistence, concurrent WinForms and Avalonia UI classes cooperate through a **shared selection +bus** and a **shared clipboard**, both bidirectional. + +```mermaid +flowchart TB + XML["XML Layout
(transitional, retire later)"]:::legacy --> IR["Typed IR
ViewDefinitionModel ✅"]:::model + LCM["LCModel"]:::model --> Region["IR-backed region model
LexicalEditRegionModel ✅ (4.8)"]:::model + IR --> Region + + Region --> Switch{"Surface-selection service ✅
per-host: supported / fallback / blocked (3.9)"}:::seam + Switch -->|Legacy host| LWF["Legacy WinForms
UNTOUCHED"]:::legacy + Switch -->|Avalonia host| AV["Avalonia host
renders region model"]:::future + + Audit["Active-host contract ✅ (3.10)
Avalonia must NOT drive a hidden DataTree"]:::test + AV --- Audit + + subgraph Substrate["Shared substrate (cooperation, bidirectional)"] + Sel["Selection bus
xCore RecordClerk / PropertyTable
'current lexeme'"]:::port + Clip["Clipboard
OS clipboard + FieldWorks WS text format"]:::port + end + LWF <--> Sel + AV <--> Sel + LWF <--> Clip + AV <--> Clip + + Ports["Shared ports — Avalonia-side only
edit-session · refresh · command/focus
(legacy NOT re-plumbed — throwaway avoided)"]:::port + AV --- Ports + + classDef legacy fill:#fff7ed,stroke:#f97316,color:#431407; + classDef future fill:#dcfce7,stroke:#16a34a,color:#052e16; + classDef model fill:#f3e8ff,stroke:#7e22ce,color:#3b0764; + classDef port fill:#dbeafe,stroke:#2563eb,color:#1e3a8a; + classDef seam fill:#fde68a,stroke:#b45309,color:#422006; + classDef test fill:#fef9c3,stroke:#ca8a04,color:#422006; +``` + +**Engineering tradeoffs accepted to reach this:** legacy is not wired through the shared ports +(two controllers coexist, but legacy regression risk → ~0 and it is deleted at cutover); shared ports +stay partly contract-only until the shell phase; the IR carries metadata only for nodes that ship; +coarse hosting on 11.x; and the dense table control (TreeView does not virtualize; TreeDataGrid went +commercial in Oct 2025 with weak editing/accessibility; ItemsRepeater is being retired) is a deliberate +later decision, not a default. \ No newline at end of file diff --git a/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md b/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md index f12aee3fc4..b7225728c3 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md +++ b/openspec/changes/lexical-edit-avalonia-migration/coverage-map.md @@ -2,7 +2,7 @@ This map records the characterization coverage needed before refactoring the standard Lexical Edit path toward Avalonia. It separates current repo behavior from proposed seams so Phase 3 does not proceed on invented interfaces. -This Phase 1/2 foundation branch keeps legacy WinForms/DataTree/XMLViews characterization and planning. The Avalonia Preview Host and `AdvancedEntry.Avalonia` prototype coverage referenced below lives on `010-advanced-entry-preview-prototype`; product launcher wiring lives on `010-advanced-entry-product-launcher-spike`. +This Phase 1/2 branch now carries legacy WinForms/DataTree/XMLViews characterization and planning, the net48 `FwAvalonia` spike and preview host, typed view-definition/seam foundation code, and product-facing app-wide lexical-edit UI mode wiring through existing `RecordEditView` hosts. The older net8 `AdvancedEntry.Avalonia` prototype remains on `010-advanced-entry-preview-prototype` as a separate prototype track. Branch scope should be checked against the branch-only diff from `main`, not inferred from same-day commit timestamps. ## Coverage Status Legend @@ -50,9 +50,9 @@ The proposed `MorphTypeSwapController` does not exist. Phase 3 should extract fr |---|---|---|---| | Browse host | [Src/xWorks/RecordBrowseView.cs](Src/xWorks/RecordBrowseView.cs) | Existing xWorks tests plus `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` for header order and filter/chooser reachability | Sort-state and keyboard-navigation baselines remain for table migration work. | | XML table renderer | [Src/Common/Controls/XMLViews/XmlView.cs](Src/Common/Controls/XMLViews/XmlView.cs) | Existing XMLViews reset/refresh tests | UIA2 or equivalent smoke harness before claiming parity. | -| Chooser forms | [Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs](Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs) and [Src/Common/Controls/XMLViews/ChooserCommandBase.cs](Src/Common/Controls/XMLViews/ChooserCommandBase.cs) | Existing isolated chooser tests, not enough for migration parity | Keyboard search, expand/collapse, double-click commit, cancel, invalid target, and transaction rollback. | +| Chooser forms | [Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs](Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs) and [Src/Common/Controls/XMLViews/ChooserCommandBase.cs](Src/Common/Controls/XMLViews/ChooserCommandBase.cs) | Existing isolated chooser tests plus `WinFormsUiaSmokeTests` realized-window smoke for chooser tree and cancel-button invoke reachability | Keyboard search, expand/collapse, accept/cancel outcome semantics, invalid target, and transaction rollback still need deeper parity coverage. | -A net48 `System.Windows.Automation` harness now exists for `FwAvaloniaPreviewHost` (`FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs`). Legacy WinForms launcher/chooser/XMLViews parity still uses in-process smoke substitutes on this branch; a full WinForms UIA2/FlaUI parity harness remains a later infrastructure decision. +A net48 `System.Windows.Automation` harness now exists for both `FwAvaloniaPreviewHost` (`FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs`) and legacy WinForms reachability smoke (`xWorksTests/WinFormsUiaSmokeTests.cs`). The current branch now has true UIA smoke for morph-type launcher/chooser reachability and XMLViews filter-bar combo reachability; deeper shell-level workflow/accessibility parity is still a later infrastructure decision. ## 5. Layout Overrides and Dictionary Configuration @@ -65,23 +65,23 @@ A net48 `System.Windows.Automation` harness now exists for `FwAvaloniaPreviewHos Override handling must be evidence-first: every selected fixture needs input XML/CSS, expected typed definition or diagnostic output, and an artifact path on mismatch. -## 6. AdvancedEntry Avalonia Seams +## 6. Avalonia Seams and Net48 Foundations -The implementation and net8 test evidence for these seams has been split to `010-advanced-entry-preview-prototype`. This foundation branch keeps the seam map so Phase 3 work does not treat prototype coverage as production parity. +The older net8-specific prototype coverage remains split to `010-advanced-entry-preview-prototype`, but this branch now contains net48 `FwAvalonia` seam contracts, typed view-definition foundation code, and net48 preview-host smoke coverage. The table below distinguishes current branch evidence from the still-separate prototype lane so Phase 3 work does not over-claim either one. | Seam | Current Source | Current Coverage | Required Before First Editable Slice | |---|---|---|---| -| Edit session | Prototype branch | Save/cancel/nested-session tests on prototype branch | Decide direct LCModel fenced undo-task vs staged draft semantics; add global undo/redo-after-save tests before product editing. | -| Validation | Prototype branch | Required-field, deterministic order, lazy skip tests on prototype branch | `INotifyDataErrorInfo` or `DataValidationErrors` adapter, localization/resource key, severity, async stale-result suppression. | -| Command/focus | Prototype branch | Local shortcut and view-model command tests on prototype branch | Text-editor focus/caret restore and popup focus return remain Phase 6 control work. XCore bridge remains shell-phase work. | -| UI scheduling | Prototype branch | Headless dispatcher tests on prototype branch | Thin scheduler fake with cancellation, exception propagation, and no false completion for `Post`. | -| Lifetime | Prototype branch | Save/cancel lifetime, late-loader disposal, close cancellation, and DataContext unsubscribe checks on prototype branch | Broader leak instrumentation remains for shell/global lifetime work. | +| Edit session | Current branch `Src/Common/FwAvalonia/Seams` + `PocEditSession`; older prototype branch keeps additional experiments | `SeamTests` contract coverage plus `PocLexEntrySliceTests` commit/cancel behavior over detached POC data | Replace detached preview-only semantics with LCModel-backed product edit-session coverage and global undo/redo-after-save tests before product editing. | +| Validation | Current branch seam specs + typed model metadata; older prototype branch keeps extra net8-specific experimentation | Current branch has typed model/view-definition foundation but not full product validation presentation evidence | `INotifyDataErrorInfo` or `DataValidationErrors` adapter, localization/resource key, severity, async stale-result suppression, and product-path tests. | +| Command/focus | Current branch seam contracts/specs plus preview-host UIA smoke; older prototype branch keeps extra local-command experiments | Preview-host UIA smoke proves stable automation identities and popup reachability for the POC host | Text-editor focus/caret restore, popup focus return in product hosts, and real XCore bridge behavior remain Phase 6 and shell-phase work. | +| UI scheduling | Current branch `IUiScheduler`/`ImmediateUiScheduler` | `SeamTests` cover the current thin scheduler seam | Cancellation, exception propagation, and no false completion for deferred work in product paths. | +| Lifetime | Current branch `IRegionLifetime`/`RegionLifetime` | `SeamTests` cover the current thin lifetime seam | Broader leak instrumentation and shell/global lifetime work remain future tasks. | ## 7. Snapshot Normalization | Surface | Current Source | Current Coverage | Remaining Phase 4 Work | |---|---|---|---| -| Presentation IR semantic snapshots | Prototype branch | Normalized LexEntry detail snapshot coverage moved to `010-advanced-entry-preview-prototype` | Replace placeholders with first-class class/flid/object/writing-system metadata once the typed definition model carries them; add foundation-level fixtures before claiming production parity. | +| Presentation IR semantic snapshots | Current branch `Src/Common/FwAvalonia/ViewDefinition` plus `ViewDefinitionTests`; older prototype branch keeps extra control-level experiments | Current branch covers determinism, stable IDs, field binding, editor classification, writing-system metadata, visibility, and expansion over the typed view-definition model | Add first-class localization/resource identity, accessibility identity, product-vs-preview routing metadata, and broader override fixtures before claiming production parity. | ## 8. Hard Gates Before Phase 3 Refactor @@ -102,12 +102,24 @@ Additional global gates: Path 3 is the migration-quality lane for judging visual fidelity: one scenario bundle combines semantic parity, visual/density parity, and accessibility/workflow parity so reviewers and AI can inspect the same evidence set. +Canonical bundle contract for every Path 3 scenario, even when only the legacy side exists so far: + +- `scenarioId`: stable scenario identifier shared by all artifacts. +- `bundleId`: concrete artifact-set identifier for the scenario run. +- `failureSummaryId`: one ID reused by semantic, visual, workflow/accessibility, and diff artifacts. +- `semantic`: semantic snapshot artifact. +- `visual.legacy`: matched WinForms screenshot(s). +- `workflow.legacy`: workflow/accessibility evidence for the legacy surface. +- `visual.avalonia`, `workflow.avalonia`, `performance`: either present artifacts or an explicit `pending` marker. + +Legacy baselines are therefore first-class Path 3 bundles, not ad hoc precursor artifacts. + | Lane | Source of Truth | Current Status | Path 3 Blocking Gaps | |---|---|---|---| | Semantic parity | `DataTreeTests` semantic baseline + typed IR snapshots | Partial | Broader fixture set for ghost/custom-field/accessibility identity; selected override fixtures. | -| Visual parity | WinForms render baselines and Avalonia rendered frames/screenshots | Partial | Canonical scenario bundle format; live Avalonia screenshots once the host work lands; matched DPI/framing rules. | +| Visual parity | WinForms render baselines and Avalonia rendered frames/screenshots | Partial | Live Avalonia screenshots once the host work lands; matched DPI/framing rules. | | Workflow/accessibility parity | UIA2/FlaUI/Appium on live windows; in-repo smoke substitutes meanwhile | Partial | Task 2.4 true UIA2/FlaUI baselines; remaining 2.7 keyboard/IME/focus-restoration/localization work. | -| Failure evidence | `RenderFailureArtifactBundler`, semantic snapshots, trace/log output | Partial | Unified failure summary id shared across semantic, visual, and workflow lanes. | +| Failure evidence | `RenderFailureArtifactBundler`, semantic snapshots, trace/log output | Partial | Shared `failureSummaryId` wiring across semantic, visual, and workflow lanes. | Path 3 blockers before a region can claim strong visual fidelity: diff --git a/openspec/changes/lexical-edit-avalonia-migration/design.md b/openspec/changes/lexical-edit-avalonia-migration/design.md index 8b43b5b527..e592d351d9 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/design.md +++ b/openspec/changes/lexical-edit-avalonia-migration/design.md @@ -2,7 +2,7 @@ Lexical Edit currently depends on a WinForms/DataTree/DetailControls stack that interprets XML Parts/Layout into `Slice` controls, launchers, chooser dialogs, nested `ViewSlice` content, and Views-backed rendering. The Advanced Entry Speckit work under `specs/010-advanced-entry-view/` already proves several useful ideas: a net8 Avalonia module, Preview Host, Presentation IR, XML contract loading, caching, headless tests, and parity checklist. The new target is larger: migrate the real Lexical Edit surface while preserving user interaction, density, writing-system behavior, and customizability, then retire XML after the Avalonia switch is proven. -This branch is the foundation slice: it documents the architecture, keeps legacy characterization tests that protect Phase 3 refactors, and now includes a net48 `FwAvaloniaPreviewHost` + preview-host UIA smoke tests for the current POC module. The older net8 Preview Host/AdvancedEntry prototype remains intentionally split to `010-advanced-entry-preview-prototype`, and product launcher wiring is intentionally split to `010-advanced-entry-product-launcher-spike`. +This branch is the current foundation-and-integration slice: it documents the architecture, keeps legacy characterization tests that protect Phase 3 refactors, and now includes the net48 `FwAvalonia` spike, typed view-definition/seam foundation code, a net48 `FwAvaloniaPreviewHost` + preview-host UIA smoke tests, and product-facing app-wide lexical-edit UI mode wiring through existing `RecordEditView` hosts. The older net8 Preview Host/AdvancedEntry prototype remains intentionally split to `010-advanced-entry-preview-prototype` as a separate prototype track. Branch scope should be reviewed against the branch-only diff from `main`, not inferred from same-day commit timestamps. Important current constraints: - `DataTree`, `Slice`, `SliceFactory`, launchers, `RecordEditView`, XMLViews browse/table views, and xCore mediator behavior are tightly coupled. @@ -19,10 +19,12 @@ Important current constraints: - Make Lexical Edit refactorable and testable before replacing major UI surfaces. - Use XML Parts/Layout as an import/compatibility contract during transition, not as the final runtime abstraction. - Introduce typed view-definition and Presentation IR interfaces suitable for dependency injection, semantic parity tests, and Avalonia rendering. +- Make the lexical-edit UI mode an app-wide product switch while keeping each current `RecordEditView` consumer on an explicit contract: supported Avalonia surface, explicit legacy fallback, or explicit blocked state. - Preserve interaction behavior, information density, writing-system fonts, OpenType/HarfBuzz shaping behavior, nested structures, popup choosers, table views, and TreeView-heavy views. - Decommission native Graphite/rendering from the default Lexical Edit path: Graphite work starts when the migration starts, and Avalonia does not become the default screen until Graphite dependencies are classified and either replaced, retained behind legacy fallback/export boundaries, or blocked with explicit diagnostics and rollback. - Decommission C++ viewing/rendering dependencies by migrated region so completed Avalonia regions do not use native Views, `RootSite`, `IVwEnv`, `ManagedVwWindow`, or equivalent C++ display/layout/editor infrastructure at runtime. Custom linguistics services may remain when they are exposed through explicit service seams and do not own Avalonia viewing or editing surfaces. - Extend render verification to capture semantic output, not only pixels and timings. +- Keep Avalonia code and tests on the normal repo build/test path; build strategy must not become the way we select legacy vs Avalonia behavior. **Non-Goals:** - No one-shot rewrite of DataTree, XMLViews, and Lexical Edit. @@ -129,6 +131,25 @@ Important current constraints: - Phase-two shell migration: invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` instead of redefining them there. - Deferred or separate-track options: package-first edit sessions, package-first undo/redo, and heavy region-lifetime frameworks remain available only if the pivot triggers documented in `seam-recommendations.md` are met. +### 12. The lexical-edit UI mode is global, but host behavior is explicit per consumer + +**Decision:** The lexical-edit UI mode is app-wide. Changing it affects every host that routes through `RecordEditView` or a later replacement, but each host must declare its behavior under both modes: supported Avalonia surface, explicit legacy fallback, or explicit blocked state with a deliberate product-facing message. + +**Rationale:** A single product switch keeps user behavior simple and prevents hidden feature islands, but a global setting cannot imply that every consumer is equally migrated. The contract must therefore stay explicit per host so unsupported surfaces do not drift into ambiguous best-effort routing. + +**Alternatives considered:** +- Per-screen or preview-only flags: rejected because they obscure the product contract and make it harder to audit what the user actually gets. +- Implicit fallback decided inside each host without a manifest: rejected because it hides migration status and encourages silent lossy routing. + +### 13. Avalonia build and test coverage stays on the normal repo workflow + +**Decision:** Avalonia projects and tests participate in the normal repo build and test flow. `./build.ps1` and `./test.ps1` remain the integration entry points; runtime UI mode selects behavior after build, not which code is built or validated. + +**Rationale:** Branch-local or optional build lanes are useful as temporary implementation details but should not define product confidence. Reviewers need one build/test story for both legacy and Avalonia code paths. + +**Alternatives considered:** +- Separate `BuildAvalonia` or preview-only validation lanes as the main evidence path: rejected because they make product validation depend on opt-in behavior rather than the normal repo workflow. + ## Native Dependency Classification The classification rule is based on the role of the native code, not the implementation language alone. If native code owns what the user is viewing or editing, it is not brought into completed Avalonia regions. If native code supplies custom linguistics capability that supports FieldWorks' role in documenting many languages, it may remain behind an explicit service seam. @@ -198,18 +219,20 @@ Pick a representative lexical path, such as LexEntry morph type plus nested sens ## Migration Plan 1. Freeze current behavior with targeted unit/integration/render/UIA2 baselines, including undo/redo, focus, keyboard/IME, accessibility, localization, customer overrides, and disposal behavior. -2. Introduce DI-friendly services around DataTree refresh, view-definition source/import/compile/cache, editor selection, command/property/navigation state, edit sessions, UI dispatch, lifetime, LCModel access, and launcher logic, following `avalonia-ui-scheduler`, `avalonia-lifetime`, and the local phase of `avalonia-command-focus`. -3. Start Graphite/native rendering decommissioning: inventory affected project settings, fonts, render engines, Gecko/PDF paths, tests, docs, and build artifacts; prove no default-path claim depends on unverified Graphite behavior. -4. Define migrated-region manifests and hard gates for each proposed Avalonia region. -5. Extend render verification with normalized semantic snapshots, visual/timing evidence, performance budgets, and failure bundles. -6. Build typed view-definition and XML import as the compatibility compiler. -7. Replace text foundation, simple controls, edit sessions, validation, undo/redo routing, and hover/popups in Avalonia using owned editor controls, following `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local phase of `avalonia-command-focus`. -8. Replace table/browse views with virtualized Avalonia table/tree structures. -9. Replace slices and full Lexical Edit views with Avalonia surfaces over the typed contract. -10. Audit the migrated region's runtime call graph and remove/disable native viewing/rendering/editor dependencies for that region, while classifying custom linguistics engines as service seams when they do not own the Avalonia UI surface. -11. Add managed canonical view-definition authoring and migration tooling. -12. Retire runtime XML only after parity gates pass for production layouts, custom fields, user overrides, dynamic editors, unsupported constructs, and fallback behavior. -13. Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` once Lexical Edit regional seams are proven. +2. Define the app-wide lexical-edit UI mode contract and explicit per-host behavior matrix before expanding product wiring beyond the first hosts. +3. Introduce DI-friendly services around DataTree refresh, view-definition source/import/compile/cache, editor selection, command/property/navigation state, edit sessions, UI dispatch, lifetime, LCModel access, and launcher logic, following `avalonia-ui-scheduler`, `avalonia-lifetime`, and the local phase of `avalonia-command-focus`. +4. Keep Avalonia build/test integration on the normal repo scripts while the runtime UI mode remains the only product selection mechanism. +5. Start Graphite/native rendering decommissioning: inventory affected project settings, fonts, render engines, Gecko/PDF paths, tests, docs, and build artifacts; prove no default-path claim depends on unverified Graphite behavior. +6. Define migrated-region manifests and hard gates for each proposed Avalonia region. +7. Extend render verification with normalized semantic snapshots, visual/timing evidence, performance budgets, and failure bundles. +8. Build typed view-definition and XML import as the compatibility compiler. +9. Replace text foundation, simple controls, edit sessions, validation, undo/redo routing, and hover/popups in Avalonia using owned editor controls, following `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local phase of `avalonia-command-focus`. +10. Replace table/browse views with virtualized Avalonia table/tree structures. +11. Replace slices and full Lexical Edit views with Avalonia surfaces over the typed contract. +12. Audit the migrated region's runtime call graph and remove/disable native viewing/rendering/editor dependencies for that region, while classifying custom linguistics engines as service seams when they do not own the Avalonia UI surface. +13. Add managed canonical view-definition authoring and migration tooling. +14. Retire runtime XML only after parity gates pass for production layouts, custom fields, user overrides, dynamic editors, unsupported constructs, and fallback behavior. +15. Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` once Lexical Edit regional seams are proven. ## Open Questions diff --git a/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md b/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md index f49f351727..3794c0298e 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md +++ b/openspec/changes/lexical-edit-avalonia-migration/phase2-execution-evidence.md @@ -2,7 +2,7 @@ This is a behavioral coverage report for the Phase 2 "Test Coverage Before Refactor" gate of `lexical-edit-avalonia-migration`. It is not a line-coverage percentage. The goal is to show which Phase 3 refactor surfaces now have executable characterization tests, where the tests live, and which gaps remain too large or too infrastructural to mark complete honestly. -This narrowed Phase 1/2 branch keeps OpenSpec planning plus legacy WinForms/DataTree/XMLViews characterization coverage. It now also contains the net48 `FwAvalonia` spike, a net48 `FwAvaloniaPreviewHost`, and `System.Windows.Automation` UIA smoke tests for the preview host. The older net8 `AdvancedEntry.Avalonia` prototype and its net8-specific host/test bootstrap remain split to `010-advanced-entry-preview-prototype`. Product command/menu wiring has been split to `010-advanced-entry-product-launcher-spike`. The unrelated `RecordList` sorting change was dropped from this scope. +This Phase 1/2 branch now keeps OpenSpec planning plus legacy WinForms/DataTree/XMLViews characterization coverage, the net48 `FwAvalonia` spike, typed view-definition and seam foundation code, a net48 `FwAvaloniaPreviewHost`, `System.Windows.Automation` UIA smoke tests for the preview host, and product-facing app-wide lexical-edit UI mode wiring through existing `RecordEditView` hosts. The older net8 `AdvancedEntry.Avalonia` prototype and its net8-specific host/test bootstrap remain split to `010-advanced-entry-preview-prototype` as a separate prototype track. Branch scope should be reviewed against the branch-only diff from `main`, not inferred from same-day commit timestamps. --- @@ -43,9 +43,12 @@ The tests below are the new coverage added from that audit. - `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` - Adds an in-repo XMLViews smoke baseline for browse-table header order and filter reachability (`Lexeme Form` filter-for path and `Morph Type` chooser filter path) without adding a new UIA2 dependency. -### Split Avalonia Prototype Boundary +### Avalonia Foundation vs Prototype Boundary -The following coverage belongs to `010-advanced-entry-preview-prototype`, not this branch: edit-session save/cancel tests, Presentation IR snapshot tests, descriptor metadata tests, validation determinism tests, Avalonia view-model lifetime tests, and snapshot failure artifacts. This branch keeps the plan and identifies those seams, but does not claim the prototype implementation as Phase 1/2 foundation evidence. +The older net8 prototype branch still owns extra prototype-specific experiments, but this branch now contains real net48 `FwAvalonia` and typed view-definition foundation evidence. The separation is therefore: + +- **Current branch evidence:** net48 `FwAvalonia` contracts/tests, typed view-definition model/importer/compiler/cache tests, net48 preview-host smoke tests, render failure artifact bundling, and product-facing UI-mode wiring tests. +- **Still split to `010-advanced-entry-preview-prototype`:** net8-specific host/bootstrap experiments and any prototype-only implementation that has not yet been promoted to the net48/product path. --- @@ -55,12 +58,12 @@ The following coverage belongs to `010-advanced-entry-preview-prototype`, not th | :--- | :--- | :--- | :--- | | 2.1 DataTree refresh state transitions and postponed `PropChanged` behavior | Covered | `MorphTypeAtomicLauncherTests`: LT-22414 tests plus `DoNotRefresh_ClearedRefreshListNeededBeforeRelease_DoesNotRefresh` | Nested `DoNotRefresh` semantics are still a design question for Phase 3 extraction. | | 2.2 Launcher pure-logic tests, morph type swap, chooser paths | Covered for Phase 2 | Full `IsStemType` matrix; no-data-loss checks; pure positive data-loss classifiers; launcher click smoke path; morph swap refresh regression tests | Full modal OK/Cancel chooser-result handling remains a Phase 3 seam-extraction target. | -| 2.3 Semantic baseline capture | Partially covered for legacy boundary | `DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder` | Typed IR/snapshot normalization moved to the preview prototype branch; ghost-state and override fixture coverage remain future work. | -| 2.4 Focused UIA2 smoke baselines | Not complete; smoke substitute only | `LauncherButtonClick_WithValidObject_ReachesChooserDecisionPath`; `FilterBar_HeaderAndFilterControlsExposeReachableBaseline` | Full UIA2/FlaUI/Appium parity harness remains future work and must not be implied by these in-repo smoke tests. | -| 2.5 Failure artifact bundling | Not covered in this branch | None in the narrowed foundation branch | Snapshot/render artifact bundling moved with the prototype and still needs render-parity evidence later. | -| 2.6 Undo/redo and LCModel transaction characterization | Not covered in this branch | None in the narrowed foundation branch | Edit-session and commit-fence characterization moved to `010-advanced-entry-preview-prototype`. | -| 2.7 Keyboard/IME, focus restoration, accessibility metadata, localization, disposal/unsubscribe | Not covered in this branch | None in the narrowed foundation branch | True text-editor IME, popup focus restoration, accessibility metadata, localization, and disposal coverage remain future/prototype work. | -| 2.8 Snapshot normalization rules | Not covered in this branch | None in the narrowed foundation branch | Normalized Presentation IR snapshots moved to `010-advanced-entry-preview-prototype`; Phase 4 still needs first-class class/flid/object/writing-system metadata. | +| 2.3 Semantic baseline capture | Covered for legacy boundary; partial for typed IR | `DataTreeTests.CfAndBib_SemanticSliceBaselineCapturesStableBindingsAndFocusOrder`; current-branch `ViewDefinitionTests` cover deterministic typed snapshot output | Broader ghost-state, accessibility-identity, and override-fixture coverage remain future work. | +| 2.4 Focused UIA2 smoke baselines | Covered for realized-window reachability smoke | `xWorksTests/WinFormsUiaSmokeTests`: realized-window UIA smoke for morph-type launcher invoke-pattern reachability, morph-type chooser tree + cancel-button invoke reachability, and XMLViews filter-bar combo reachability; preview-host UIA smoke remains in `FwAvaloniaPreviewHostTests` | Broader shell-level workflow/accessibility parity and richer chooser outcome semantics still remain future work. | +| 2.5 Failure artifact bundling | Covered in this branch | `RenderFailureArtifactBundler` and `RenderFailureArtifactBundlerTests` in the current branch | Broader parity-bundle stitching still remains future work. | +| 2.6 Undo/redo and LCModel transaction characterization | Covered for current legacy/editor-candidate boundary | `DataTreeUndoRedoCharacterizationTests` in the current branch | Product-path Avalonia edit-session/commit-fence coverage remains future work. | +| 2.7 Keyboard/IME, focus restoration, accessibility metadata, localization, disposal/unsubscribe | Covered for current first-slice characterization scope | `DataTreeDisposalCharacterizationTests`; `MorphTypeAtomicLauncherTests.DoNotRefresh_RemainingSliceRestoresFocus_AfterRefreshRebuild`; `PocLexEntrySliceTests` focused Unicode text entry + stable automation metadata; `LexOptionsDlgTests.UIModeControls_ReadDisplayTextFromResx` | Deeper live-app IME composition remains a richer future parity lane, but current first-slice characterization coverage is now present in-repo. | +| 2.8 Snapshot normalization rules | Covered for current typed view-definition foundation | Current-branch `ViewDefinitionTests` plus `ViewDefinitionModel.ToSnapshot` normalization | Phase 4 still needs first-class localization/resource identity, accessibility identity, and broader override fixtures. | --- diff --git a/openspec/changes/lexical-edit-avalonia-migration/proposal.md b/openspec/changes/lexical-edit-avalonia-migration/proposal.md index 68197fb197..1ba4bfea8c 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/proposal.md +++ b/openspec/changes/lexical-edit-avalonia-migration/proposal.md @@ -2,13 +2,14 @@ Lexical Edit is the main editing surface in FLEx, but its current WinForms/DataTree/XMLViews architecture mixes view definition, control creation, LCModel access, refresh state, and legacy rendering concerns in ways that make the Avalonia migration risky. The existing Advanced Entry Speckit work proves useful pieces of an Avalonia path, but the broader migration needs an OpenSpec plan that treats XML Parts/Layout as a transitional compatibility contract and makes testability/refactoring the first-class work before replacing UI. -Current branch scope: this Phase 1/2 foundation branch contains OpenSpec planning, migration-review skills, legacy WinForms/DataTree/XMLViews characterization coverage, the net48 `FwAvalonia` spike, and a net48 `FwAvaloniaPreviewHost` + preview-host UIA harness. The older net8 `AdvancedEntry.Avalonia` prototype remains on `010-advanced-entry-preview-prototype`; product command/menu wiring is split to `010-advanced-entry-product-launcher-spike`; the unrelated `RecordList` sorting change was dropped. +Current branch scope is judged by the branch-only diff against `main`, not by same-day commit timestamps. This Phase 1/2 branch now contains OpenSpec planning, migration-review skills, legacy WinForms/DataTree/XMLViews characterization coverage, the net48 `FwAvalonia` spike, a net48 `FwAvaloniaPreviewHost` + preview-host UIA harness, typed view-definition and seam foundation code, and product-facing app-wide lexical-edit UI mode wiring through existing `RecordEditView` hosts. The older net8 `AdvancedEntry.Avalonia` prototype remains on `010-advanced-entry-preview-prototype` as a separate prototype track. ## What Changes - Migrate the Advanced Entry Speckit research, parity checklist, and task intent into OpenSpec under a broader Lexical Edit migration change. - Establish a phased migration contract: baseline tests first, legacy refactoring seams second, then Avalonia simple controls/popups, table views, slices, and full Lexical Edit views. - Introduce a typed, managed view-definition/Presentation IR as the migration boundary. Existing XML Parts/Layout remains an import source during transition; long-term runtime XML dependency is retired only after parity is proven. +- Treat lexical-edit UI mode as an app-wide product switch while keeping host behavior explicit per consumer: every current `RecordEditView` consumer must declare whether Avalonia mode is supported, falls back to legacy, or is blocked with a deliberate product-facing diagnostic. - Make native viewing/rendering decommissioning a completion gate for each migrated region: if native code owns display, layout, measurement, hit testing, selection, or editor realization, it SHALL NOT be brought into the completed Avalonia region. Custom linguistics engines and native services such as XAmple, spelling, parser/conversion tools, or similar language-documentation capability may remain when isolated behind service seams outside the Avalonia render/editor path. - Start Graphite/native-rendering decommissioning with the migration. Avalonia SHALL NOT become the default Lexical Edit screen until Graphite font settings, native Graphite engines, Gecko Graphite rendering, PDF/export assumptions, tests, docs, and build/package artifacts are inventoried, classified, and either replaced, retained behind a legacy boundary, or blocked with explicit diagnostics and rollback. - Require dependency-injected services around DataTree/Slice/Launcher behavior, view-definition source/import/compile/cache, editor selection, edit sessions, LCModel transactions, undo/redo grouping, validation, command/focus routing, UI dispatch, lifetime/disposal, diagnostics, and render/parity capture. @@ -16,6 +17,7 @@ Current branch scope: this Phase 1/2 foundation branch contains OpenSpec plannin - Define migrated-region manifests and hard gates so each claimed Avalonia region has explicit entry points, allowed legacy adapters, forbidden native/Graphite call paths, custom linguistics service dependencies, parity fixtures, performance budgets, and rollback/default-switch rules. - Extend render verification from pixel/timing snapshots to semantic parity snapshots covering legacy WinForms/DataTree, typed IR, and Avalonia output. - Define automation strategy: UIA2/FlaUI-style tests for legacy WinForms workflow reachability; Avalonia.Headless tests for new controls; layered unit/integration tests for IR, LCModel, refresh, and transactions. +- Keep Avalonia build and test participation on the normal repo scripts (`./build.ps1`, `./test.ps1`). The legacy-vs-Avalonia choice is a runtime product behavior switch, not a separate build lane. - Allow Avalonia package updates or targeted upstream/local control work when stock controls cannot preserve FieldWorks density, interaction semantics, OpenType/HarfBuzz text, or TreeView requirements. ## Non-goals @@ -50,7 +52,7 @@ Current branch scope: this Phase 1/2 foundation branch contains OpenSpec plannin ## Impact -- Managed code: `Src/Common/Controls/DetailControls/`, `Src/Common/Controls/XMLViews/`, `Src/xWorks/`, `Src/LexText/`, future/split `Src/LexText/AdvancedEntry.Avalonia/`, future/split `Src/Common/FwAvalonia/`, `Src/Common/RenderVerification/`, and related managed test projects. +- Managed code: `Src/Common/Controls/DetailControls/`, `Src/Common/Controls/XMLViews/`, `Src/xWorks/`, `Src/LexText/`, `Src/Common/FwAvalonia/` (the owning Avalonia project on this branch; `AdvancedEntry.Avalonia` is the prototype-branch reference only), `Src/Common/RenderVerification/`, and related managed test projects. - Native code: no native viewing/rendering path is planned for completed Avalonia regions. Existing Views/native rendering remains in baseline and comparison scope until replaced, but the dependency audit for each migrated region must prove there is no runtime call path through native display, layout, measurement, hit testing, selection, or editor-realization code before that region is considered complete. Native custom linguistics services that support FieldWorks' language-documentation mission, such as XAmple, spelling, parser/conversion tools, ICU, or Encoding Converters, may remain as explicit service dependencies when kept outside the Avalonia render/editor boundary. Graphite native code and render-engine selection are explicitly in inventory/decommissioning scope for the migrated default path, while legacy fallback/export consumers are classified separately. - Browser/export code: Gecko/XULRunner initialization currently enables Graphite rendering and `XWebBrowser`/`GeckofxHtmlToPdf` support preview, print, and PDF flows. Those paths must be audited, replaced, or moved outside the default Avalonia Lexical Edit boundary before default switch. - Configuration: `DistFiles/Language Explorer/Configuration/Parts/*.fwlayout` and `*Parts.xml` become migration inputs to a managed typed view definition rather than the long-term runtime UI format. diff --git a/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md index 1eb5607207..c0168ee566 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md +++ b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md @@ -2,6 +2,14 @@ The manifest is the contract for what a migrated Lexical Edit region owns, what legacy services it may adapt, and what dependencies are forbidden from the new default path. It is not implemented yet; Phase 3 must introduce it behind a default-off switch and executable audits. +> **Branch note (2026-06-09).** The owning project is **`SIL.FieldWorks.Common.FwAvalonia`** +> (`Src/Common/FwAvalonia`). `AdvancedEntry.Avalonia` exists only on the prototype branch +> `010-advanced-entry-preview-prototype` and is a reference implementation, not shipped code. The +> **active-host contract** referenced by `forbiddenSymbols` (`DataTree`, `Slice`, …) is now partially +> enforced: see `ActiveHostContract` in `FwAvalonia/Seams` and the `RecordEditView` audit test +> (`RecordEditViewActiveHostContractTests`) proving the active Avalonia path does not drive a hidden +> legacy `DataTree`. + ## 1. Manifest Shape Each migrated region should declare: @@ -9,8 +17,9 @@ Each migrated region should declare: | Field | Meaning | |---|---| | `regionId` | Stable identifier such as `lexical-edit.entry.identity`. | -| `ownerProject` | Owning project/module, for this change `AdvancedEntry.Avalonia`. | +| `ownerProject` | Owning project/module, for this change `FwAvalonia` (`SIL.FieldWorks.Common.FwAvalonia`). | | `legacySurface` | Legacy host/slice/layout being replaced or wrapped. | +| `uiModeBehavior` | For each app-wide UI mode, declares whether this host is supported, explicitly falls back to legacy, or is blocked with a product-facing diagnostic. | | `enabledByDefault` | `false` until all gates pass for that region. | | `rollbackSurface` | Legacy view or command used when the migrated region is disabled or fails capability checks. | | `allowedAdapters` | Narrow legacy services the region may call. | @@ -23,8 +32,12 @@ Example draft: ```json { "regionId": "lexical-edit.entry.identity", - "ownerProject": "AdvancedEntry.Avalonia", + "ownerProject": "FwAvalonia", "legacySurface": "LexEntry-detail-Normal identity fields in DataTree", + "uiModeBehavior": { + "Legacy": "legacy-active", + "New": "supported-avalonia" + }, "enabledByDefault": false, "rollbackSurface": "RecordEditView/DataTree", "allowedAdapters": [ @@ -89,6 +102,7 @@ Exceptions must be documented in the manifest with owner, reason, tests, and rol | Gate | Required Evidence | |---|---| +| Switch contract gate | Every affected host declares `uiModeBehavior` for both app-wide UI modes, and no host relies on ambiguous best-effort routing. | | Schema gate | Manifest validates against a checked-in schema and has an owner/rollback/test evidence entry. | | Symbol audit gate | Automated search over migrated production code finds no forbidden symbols except approved exceptions. | | Layout gate | Typed presentation snapshot matches selected DataTree/XML layout baselines for the region. | diff --git a/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md b/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md new file mode 100644 index 0000000000..b26284d882 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md @@ -0,0 +1,69 @@ +# Seam Domain Comparison + +This note makes the seams concrete by comparing, per domain, four states: + +1. **Before** — how it worked before this migration effort. +2. **Ideal** — the clean end-state after WinForms is removed. +3. **Now** — what actually exists on this branch (`010-advanced-entry-view-phase-1-2`). +4. **Recommended** — what to build during the coexistence year (Path 3). + +It complements `seam-recommendations.md` and `architecture-diagrams.md`. + +## Constraints (fixed) + +- **Avalonia 11.x only** until WinForms is gone (no Avalonia 12 message filter / dispatcher work; + cross-boundary tab/focus and popup-DPI are ours to work around; host coarsely). +- **~1-year coexist phase, then WinForms deleted.** Each UI *class* is wholly one framework, but + different classes run **concurrently** and cooperate via **selection** and **copy/paste**. +- **XML-layout retirement is a separate effort** — keep XML→IR import; the typed IR is the runtime + contract the Avalonia side consumes. + +## What "throwaway" means here + +Throwaway = wiring the new ports into **legacy internals** (e.g. threading `RefreshCoordinator` into +the live `DataTree`, or `LexicalEditorRegistry` into `SliceFactory`), because that code is deleted at +cutover. **Not** throwaway: the cross-framework **selection** and **copy/paste** bridges — they are +real, must-build, and bidirectional, and the selection concept outlives WinForms. + +## A. Routing & view model + +| Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | +|---|---|---|---|---|---| +| Surface routing | `LexicalEditSurfaceResolver`/`Factory` + `LexicalEditSurfaceSelectionService` | `RecordEditView` always builds `DataTree` | No switch — only Avalonia | Resolver/factory built, tested, wired to `UIMode`; selection service added (3.9) | The one load-bearing clean seam. Route every host decision through the selection service | +| View definition / layout | typed IR (`ViewDefinitionModel`) | XML parsed straight into slices at runtime | Typed IR is authoring + runtime contract; XML gone | IR built, tested, **now consumed** by the region model (4.8) | Keep XML→IR import; defer XML retirement to its own effort | +| Editor/slice selection | `ILexicalEditorRegistry` | `SliceFactory` reads XML, picks WinForms slice | Registry resolves IR editor-kind → Avalonia control | Registry + fallback implemented; **not** wired into live `SliceFactory` | Wire registry on the **Avalonia** side only; leave legacy `SliceFactory` untouched | + +## B. Editing core + +| Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | +|---|---|---|---|---|---| +| Edit session (commit/cancel/dirty) | `IEditSession` | Slices write LCModel inline, fenced ad hoc | One fenced LCModel session per edit | `PocEditSession` snapshots the **DTO**, not LCModel; real fenced session is on the prototype branch only | Reproduce the prototype's LCModel-fenced session behind `IEditSession` for the first product editor (6.x) | +| Undo/redo | (`IUndoRedoCoordinator`, not built) | LCModel `IActionHandler` stack authoritative | Global LCModel undo authoritative; control-local text undo as leaf | Local save/cancel only | Global undo = LCModel always; let Avalonia `TextBox` keep local text undo; commits route through the session | +| LCModel access / write-back | `IEditSession` + region builder | Slices read+write LCModel objects directly | Avalonia binds to a model-backed region VM; writes via session | Product route built a **read-only lossy** projection (`LexicalEditPocMapper`); replaced by typed-definition-backed region model (4.8) | Region model is the product contract; rich editing + write-back through the session is 6.x/7.x | +| Validation | validation seam | Inline in slices / native | Domain validation + Avalonia `INotifyDataErrorInfo` adapter | None on this branch (prototype-branch only) | Reproduce behind the seam when the first editable slice lands | + +## C. The coexistence boundary (where 11.x bites) + +| Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | +|---|---|---|---|---|---| +| Selection sync ("current lexeme") | `IRecordNavigationContext` + `IPropertyStateStore` | WinForms views follow xCore `RecordClerk`/PropertyTable "current record" broadcast | Same bus; Avalonia is first-class publisher+subscriber | `IRecordNavigationContext` contract-only; `IPropertyStateStore` in-memory only | **Build it (not throwaway).** Bidirectional: the active surface *follows* the broadcast and *publishes* its own selection back. The bus already exists | +| Copy / paste | clipboard seam (not built) | Native Views clipboard (rich/structured TsString) | WS-aware framework-neutral clipboard | Unaddressed | **Build a shared FieldWorks clipboard format** (serialized multi-WS/TsString) both native-Views and Avalonia read/write, plus plain-text fallback; both hit the OS clipboard. Decide target fidelity early — rich Views formats won't round-trip natively | +| Focus / keyboard / tab | host edge | WinForms tab order across slices | Pure Avalonia focus within one host | Coarse hosting via `WinFormsAvaloniaControlHost` | Host coarsely — one big Avalonia view per host. Own focus *inside* the Avalonia view; don't fight cross-boundary Tab (open 11.x bug) | +| Command routing (menus/xCore) | `IXCoreCommandBridge` | xCore mediator routes to active target | Avalonia commands + thin xCore bridge at shell phase | Contract-only | Bridge only the commands this screen needs this year; defer the general bridge to the shell migration | + +## D. Text & rendering + +| Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | +|---|---|---|---|---|---| +| WS text / fonts / IME | `IWritingSystemTextService` | Native Views shaping + project WS fonts | Managed WS service feeds Avalonia text (HarfBuzz/OpenType) | POC bakes font into the DTO; region field now carries ws + (optional) font hints | Build a real WS→font/flow service from project settings; verify IME per-WS on 11.x | +| Choosers / popups | `IChooserService` | WinForms modal chooser dialogs | Avalonia flyouts + service-backed chooser model | POC `MorphTypePopupChooser` = hardcoded flyout | Real chooser **service** returning LCModel-sourced options; watch 11.x popup-DPI | +| Native rendering (RootSite/Views/Graphite) | audit test | Everything via native Views/C++ + Graphite | Excluded from Avalonia default path | POC audited to have zero Graphite/Views/RootSite refs | Keep the audit gate; legacy keeps native rendering until deleted | + +## E. Infrastructure + +| Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | +|---|---|---|---|---|---| +| Refresh coordination | `ILexicalRefreshCoordinator` | `DataTree` `DoNotRefresh`/`RefreshPending` flags inline | One coordinator both surfaces honor | `RefreshCoordinator` models the gate, tested; **not** wired into live `DataTree` | Wire on the Avalonia side; leave legacy inline flags alone (throwaway) | +| Lifetime / disposal | `IRegionLifetime` | Slices dispose ad hoc | Explicit region ownership tree | Implemented + tested | Use for the Avalonia host/region | +| UI scheduling / threading | `IUiScheduler` | WinForms `Control.Invoke` | Single dispatcher, marshalled | `ImmediateUiScheduler` (tests) + dispatcher at edge | Keep thin; single UI thread on 11.x | +| Host/surface contract | `ILexicalEditHost`/`ILexicalEditSurface` | Implicit in `RecordEditView` | Explicit init/focus/context-menu/replacement contract | Contracts defined (3.5); `RecordEditView` conforms via the selection service | Formalize the active-host contract (3.10) as an audited invariant | diff --git a/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md index d7f6969b16..db7525344f 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md +++ b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md @@ -2,6 +2,55 @@ This note records the recommended seam direction for the Lexical Edit Avalonia migration. It is advisory; the companion seam docs define the concrete gates and tests. Current implementation is deliberately distinguished from proposed seams. +> **Branch-state correction (2026-06-09).** Earlier revisions of this note described +> `AdvancedEntryEditSession`, `ValidationService`, and `MainWindowViewModel` as the "current +> implementation." Those types are **not present on this branch** (`010-advanced-entry-view-phase-1-2`). +> They live on the prototype branch `010-advanced-entry-preview-prototype` and were never merged here. +> What this branch actually contains is: the typed seam interfaces in `FwAvalonia/Seams/ISeams.cs` +> (5 with pure-logic implementations, `IXCoreCommandBridge`/`IRecordNavigationContext` contract-only, +> `IEditSession` with a `PocEditSession` DTO-snapshot stub), the typed view-definition IR under +> `FwAvalonia/ViewDefinition/`, and the feature-flagged POC host (`FwAvalonia/Poc/` + +> `RecordEditView`). The "Current implementation" lines below have been re-labelled accordingly: +> the prototype is a **reference to reproduce behind the seam**, not shipped code in this branch. +> See `seam-domain-comparison.md` for the per-domain before/ideal/now/recommended breakdown. + +## Coexistence constraints driving these recommendations + +These are fixed product constraints; the seam choices below assume them: + +1. **Avalonia 11.x only during coexistence.** We stay on the latest 11.x and do **not** move to + Avalonia 12 until WinForms is fully removed. So the Avalonia 12 WinForms message filter and + per-thread dispatcher work are unavailable; cross-interop-boundary tab/focus + ([AvaloniaUI/Avalonia#12025](https://github.com/AvaloniaUI/Avalonia/issues/12025)) and popup-DPI + quirks must be handled by us, and we host **coarsely** (one Avalonia view per host, not many + small islands sharing a WinForms tab order). +2. **~1-year coexist phase, then WinForms is deleted.** Each *class* of UI is wholly WinForms **or** + wholly Avalonia at a time, but different classes run **concurrently** and must cooperate through + two shared channels: **selection** ("this is the current lexeme") and **copy/paste**. Those two + bridges are real, must-build, and bidirectional — not throwaway scaffolding. What *is* throwaway + is wiring the new ports into *legacy internals* (e.g. threading `RefreshCoordinator` into the live + `DataTree`), because that code is deleted at cutover. +3. **XML-layout retirement is a separate effort.** Moving authoring off XML Parts/Layout to a modern + typed format is desirable but out of scope here; this change keeps the XML→IR importer and treats + the typed IR as the runtime contract the Avalonia side consumes. + +## Recommended path (Path 3): thin enforced surface seam + sequenced convergence + +The clean seam is **not** legacy re-plumbed through every port. It is (a) the surface-selection +boundary (`LexicalEditSurfaceResolver`/`Factory` + the new `LexicalEditSurfaceSelectionService`) and +(b) the typed IR as the data contract the Avalonia side consumes. Legacy stays frozen behind the +switch until cutover. Concretely: + +- Enforce the **active-host contract** (3.10): the active Avalonia path must not instantiate or drive + a hidden legacy `DataTree`. This was violated (the POC drove `m_dataEntryForm.ShowObject` then hid + it); it is now an audited invariant. +- Replace the **lossy `LexicalEditPocMapper` DTO** on the product route with a + **typed-definition-backed region model** (4.8); keep `PocEntryDto` for the preview host only. +- Build the **selection and clipboard bridges** as bidirectional adapters over the shared xCore/LCModel + substrate; do not re-plumb legacy internals. +- Reproduce the prototype's **LCModel-fenced edit session and validation** behind `IEditSession`/the + validation seam when the first product editor lands (6.x); the prototype branch is the reference. + Supporting docs: - `avalonia-edit-sessions.md` @@ -13,7 +62,7 @@ Supporting docs: ## Edit Sessions -**Current implementation:** `AdvancedEntryEditSession` is a concrete fenced LCModel undo-task session with `Save()` and `Cancel()`. +**Current implementation (this branch):** `IEditSession` is defined; the only implementation is `PocEditSession`, which snapshots/restores the **detached POC DTO**, not LCModel. A real fenced LCModel undo-task session (`AdvancedEntryEditSession`, with `Save()`/`Cancel()`) exists **only on the prototype branch** `010-advanced-entry-preview-prototype` and is the reference to reproduce behind `IEditSession`. **Recommendation:** Keep the direct LCModel fenced undo-task model for the first editable slice, then extract a FieldWorks-owned edit-session seam only with lifecycle, rollback, and global undo/redo tests. @@ -59,7 +108,7 @@ Cons: requires explicit focus/command routing rules. ## Validation -**Current implementation:** `ValidationService` performs deterministic required-field checks over Presentation IR and skips unmaterialized lazy items. +**Current implementation (this branch):** none. A `ValidationService` performing deterministic required-field checks over Presentation IR (skipping unmaterialized lazy items) exists **only on the prototype branch** `010-advanced-entry-preview-prototype`; reproduce it behind the validation seam when the first editable slice lands. **Recommendation:** Use a FieldWorks-owned validation model with Avalonia presentation adapters, preferably `INotifyDataErrorInfo` or `DataValidationErrors` where that maps cleanly to controls. @@ -81,7 +130,7 @@ Cons: requires structured issue paths and localization contract. ## Command and Focus -**Current implementation:** the spike has local Avalonia key bindings and view-model commands. There is no XCore command bridge yet. +**Current implementation (this branch):** `IXCoreCommandBridge` is contract-only (no implementation). The local Avalonia key bindings and view-model commands referenced here are on the prototype branch `010-advanced-entry-preview-prototype`, not this branch. **Recommendation:** Use local Avalonia commands for first-slice/preview behavior; introduce a FieldWorks/XCore bridge only during shell integration. @@ -103,7 +152,7 @@ Cons: easy to over-preserve legacy quirks if introduced too early. ## UI Scheduler -**Current implementation:** dispatcher calls are used directly in the Avalonia module; no shared scheduler seam exists. +**Current implementation (this branch):** `IUiScheduler` is defined with an `ImmediateUiScheduler` (synchronous, for tests/non-view code); the live app supplies a dispatcher-backed scheduler at the view edge. Direct dispatcher use in the prototype module lives on `010-advanced-entry-preview-prototype`. **Recommendation:** Introduce a thin `IUiScheduler` only where non-view code needs testable UI-thread marshalling, cancellation, or exception propagation. Keep direct dispatcher use at concrete UI edges. @@ -125,7 +174,7 @@ Cons: unnecessary as global default. ## Lifetime -**Current implementation:** `MainWindowViewModel` owns and disposes the loaded lifetime on save/cancel; no `ILexicalLifetimeManager` exists. +**Current implementation (this branch):** `IRegionLifetime`/`RegionLifetime` is implemented and tested (reverse-order, idempotent disposal). The `MainWindowViewModel` ownership pattern referenced here is on the prototype branch `010-advanced-entry-preview-prototype`. **Recommendation:** Keep ownership explicit in the view model for the first slice; extract a lifetime manager only after late-loader, idempotent-disposal, event-unsubscribe, and shell-unload tests exist. diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md index a081ce61de..b93ab061df 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-avalonia-migration/spec.md @@ -14,6 +14,24 @@ The Lexical Edit migration SHALL proceed in phases: baseline test coverage, refa - **THEN** simple editors and popup hovers SHALL be attempted before table views - **AND** table views SHALL be attempted before slice and full Lexical Edit replacement +### Requirement: Lexical-edit UI mode is app-wide and explicit per host + +The lexical-edit UI mode SHALL be an app-wide product setting, but each current host that routes through `RecordEditView` or a later replacement SHALL declare explicit behavior for both modes: supported Avalonia surface, explicit legacy fallback, or explicit blocked state with a deliberate product-facing diagnostic. + +#### Scenario: Global UI mode changes host behavior predictably +- **WHEN** the app-wide lexical-edit UI mode is changed +- **THEN** every affected host SHALL resolve to its declared behavior for that mode +- **AND** no host SHALL rely on ambiguous best-effort routing or silent lossy fallback + +#### Scenario: Legacy UI remains selectable +- **WHEN** the app-wide lexical-edit UI mode is set to the legacy option +- **THEN** the existing WinForms lexical-edit surface SHALL remain selectable as the supported legacy product path + +#### Scenario: Unsupported host is explicit +- **WHEN** a host is not yet migrated for the Avalonia mode +- **THEN** it SHALL either fall back to the declared legacy surface or show a deliberate resource-backed unsupported-state message +- **AND** that behavior SHALL be covered by tests and the migrated-region manifest + ### Requirement: User interaction and density are preserved Avalonia replacements SHALL preserve the legacy user interaction model, information density, keyboard/focus behavior, popup semantics, and layout hierarchy within documented near-pixel tolerances. @@ -94,6 +112,20 @@ Avalonia regions SHALL use explicit services for UI dispatch, focus navigation, - **THEN** that behavior SHALL be routed through explicit command/focus services - **AND** Avalonia.Headless or semantic parity tests SHALL cover the behavior +### Requirement: Avalonia build and test coverage uses normal repo workflows + +Avalonia projects and tests SHALL participate in the normal repo build and test workflows. The legacy-vs-Avalonia choice SHALL be a runtime product behavior switch, not a separate build-lane contract. + +#### Scenario: Normal build includes Avalonia companion projects +- **WHEN** `./build.ps1` builds the branch +- **THEN** branch-local net48-compatible Avalonia companion projects required by this change SHALL participate in the normal repo build flow +- **AND** their inclusion SHALL NOT depend on a separate product-mode selection step + +#### Scenario: Normal test path covers Avalonia tests when touched +- **WHEN** Avalonia code or tests are part of the change under review +- **THEN** `./test.ps1` and its normal build/test path SHALL be the expected validation entry point +- **AND** the runtime UI mode SHALL remain a product behavior choice rather than a different test lane + ### Requirement: Package updates and control hacks are gated by parity evidence Avalonia package updates, third-party control additions, upstream patches, or local control hacks SHALL be allowed only when tied to a specific parity, density, text, table, or automation requirement. diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md index e62247fdaa..a8b6afc867 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-parity-automation/spec.md @@ -66,6 +66,18 @@ Each bundle SHALL contain: - accessibility/workflow evidence for focus, invoke, popup reachability, and automation identity, - a failure summary classifying the mismatch by lane. +Each bundle SHALL also carry one shared identity contract: + +- `scenarioId` for the business scenario under test, +- `bundleId` for the specific artifact set, +- `failureSummaryId` shared across semantic, visual, workflow/accessibility, and diff artifacts, +- an explicit lane manifest saying which lanes are present (`semantic`, `visual`, `workflow/accessibility`, `performance`) and which are pending. + +#### Scenario: Legacy baseline bundle is canonical before Avalonia comparison exists +- **WHEN** only the legacy WinForms side of a Path 3 scenario has been captured so far +- **THEN** the canonical bundle SHALL still exist with the shared `scenarioId`, `bundleId`, and `failureSummaryId` +- **AND** it SHALL include the semantic snapshot, matched WinForms screenshot(s), workflow/accessibility evidence, and lane manifest marking the Avalonia visual or workflow lanes as pending rather than omitting the bundle contract entirely + #### Scenario: Control-level Avalonia visual evidence uses headless rendering - **WHEN** the parity target is an Avalonia control or region that can be evaluated without product-shell integration - **THEN** the visual lane MAY use Avalonia.Headless rendered frames diff --git a/openspec/changes/lexical-edit-avalonia-migration/tasks.md b/openspec/changes/lexical-edit-avalonia-migration/tasks.md index 2a899cbc76..ace7e92a00 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/tasks.md +++ b/openspec/changes/lexical-edit-avalonia-migration/tasks.md @@ -9,18 +9,29 @@ - [x] 1.5 Start Graphite decommissioning inventory for writing-system settings, fonts, native render engines, Gecko/browser/PDF paths, tests, docs, sample assets, and build/package artifacts. - [x] 1.6 Define migrated-region manifest format: entry points, allowed legacy adapters, forbidden symbols/call paths, custom linguistics service dependencies, parity fixtures, performance budgets, accessibility IDs, and rollback/default-switch gates. - [x] 1.7 Freeze and maintain the seam capability docs `avalonia-edit-sessions`, `avalonia-undo-redo`, `avalonia-validation`, `avalonia-command-focus`, `avalonia-ui-scheduler`, `avalonia-lifetime`, and `seam-recommendations.md` as the reference playbook for both this change and the later shell change. +- [x] 1.8 Audit migration docs and evidence notes for stale branch/scope assumptions after each wiring or build-strategy change; compare against `main..HEAD`, not calendar-day commit lists, and keep proposal/design/coverage/evidence wording aligned with the live branch scope. +- [x] 1.9 Define the global lexical-edit UI mode contract: the switch is app-wide, legacy UI remains selectable, and every current `RecordEditView` consumer has an explicit expected behavior (`Avalonia`, explicit legacy fallback, or blocked state) under both modes. +- [x] 1.10 Define the build/test integration contract for Avalonia: Avalonia projects and tests participate in normal `./build.ps1` and `./test.ps1` coverage; runtime legacy-vs-Avalonia selection is a product behavior switch, not a separate build lane. +- [ ] 1.11 Execute the 1.10 contract: add `FwAvalonia` and `FwAvaloniaTests` to `FieldWorks.proj` (traversal) and `FieldWorks.sln` so `./build.ps1`/`./test.ps1` actually build and run them, and remove the "intentionally NOT added" isolation note from `FwAvalonia.csproj`. The spike handoff (`lexical-edit-avalonia-poc-spike/spike-evidence.md`) gated this on live-embedding evidence, which now exists (`RecordEditView` product wiring, preview-host UIA smoke, `RecordEditViewActiveHostContractTests`). Until this lands, design decision 13 is unmet and the primary evidence path is a branch-only lane. +- [x] 1.12 Refresh `lexical-edit-avalonia-poc-spike/spike-evidence.md`: close Pending #1 (in-process embedding) with the product-wiring evidence that now exists; keep DPI density measurement and the rendered-frame native/Graphite assertion (Pending #2/#3) explicitly open with owners and target tasks. (Done 2026-06-09: Pending #1 marked closed with `RecordEditView`/`RecordEditViewActiveHostContractTests` evidence; #2 still open, #3 pointed at 6.9/8.4; handoff note now flags build-graph integration as due via 1.11.) +- [x] 1.13 Reconcile the roadmap with the boundary actually built: `avalonia-migration-roadmap` defines Gate 1 around `datatree-model-view-separation` (`DataTreeModel`/`SliceSpec`/`IDataTreeView`), but execution went directly to this change's region-model boundary (`ViewDefinitionModel`/`LexicalEditRegionModel` through `RecordEditView`), and `seam-domain-comparison.md` now classifies wiring new ports into legacy `DataTree` internals as throwaway. Either formally supersede/re-scope `datatree-model-view-separation` and redefine Gate 1 around the region-model boundary, or document how the two boundaries converge. The repo must not carry two competing boundary vocabularies. (Done 2026-06-09: `datatree-model-view-separation` formally superseded as a migration gate — `DataTree` is frozen legacy, `DataTreeModel`/`SliceSpec`/`IDataTreeView` should not be built, optional partial-class/characterization work is maintenance only. Gate 1 redefined around the region-model boundary in `avalonia-migration-roadmap/design.md` with as-built vocabulary diagram. Both the roadmap `proposal.md`/`design.md` and `datatree-model-view-separation/hybrid-alignment.md`/`proposal.md` carry superseded banners.) +- [x] 1.14 Standardize the owning-project name: replace remaining `AdvancedEntry.Avalonia` references in `region-manifest.md` (manifest-shape table and example JSON) and the `proposal.md` Impact section with `FwAvalonia`; keep a single glossary note mapping the prototype branch's `AdvancedEntry.Avalonia` to its reference-only role. (Done 2026-06-09: `region-manifest.md` table/example and branch note, `proposal.md` Impact updated; prototype branch named as reference-only.) ## 2. Test Coverage Before Refactor - [x] 2.1 Add or extend unit/integration tests for DataTree refresh state transitions and postponed `PropChanged` behavior. - [x] 2.2 Add or extend launcher pure-logic tests, prioritizing morph type swap/data-loss logic and chooser decision paths. - [x] 2.3 Add semantic baseline capture for current DataTree/Slice output: labels, object/flid bindings, editor kind, visibility, expansion, focus order, and accessibility identity. -- [ ] 2.4 Add true UIA2/FlaUI/Appium smoke baselines for WinForms launcher/chooser workflows and XMLViews table header/filter reachability. The current branch has in-repo smoke substitutes only. (In-process accessibility/focus-order substitutes added in `DataTreeDisposalCharacterizationTests`; a true UIA2/FlaUI host still requires the running app and remains pending.) +- [x] 2.4 Add true UIA2/FlaUI/Appium smoke baselines for WinForms launcher/chooser workflows and XMLViews table header/filter reachability. (`WinFormsUiaSmokeTests` in `xWorksTests`: realized-window UIA smoke for morph-type launcher invoke-pattern reachability, morph-type chooser tree + cancel-button invoke reachability, and XMLViews filter-bar combo reachability via stable nonlocalized filter-combo IDs.) - [x] 2.5 Add failure artifact bundling to render/parity tests where missing. (`RenderFailureArtifactBundler` in RenderVerification bundles received/diff images + `failure-summary.json` into a CI-discoverable folder; wired into `DataTreeRenderTests.VerifyDataTreeBitmap`; covered by `RenderFailureArtifactBundlerTests` (4 tests).) - [x] 2.6 Add undo/redo and LCModel transaction characterization tests for editor replacement candidates. (`DataTreeUndoRedoCharacterizationTests`: CitationForm/Bibliography multistring edits revert/replay, multi-field single-task = single undo step, consecutive edits = distinct steps, slice reflects reverted model after reshow. Runs against real DataTree/Slice on net48.) -- [ ] 2.7 Add keyboard/IME, focus restoration, accessibility metadata, localization, and disposal/unsubscribe characterization tests for first-slice candidates. (Disposal/unsubscribe + accessibility-name + focus-order done in `DataTreeDisposalCharacterizationTests` (7 tests, real DataTree/Slice); keyboard/IME, focus restoration across refresh, and localization still pending and partly need a running app.) +- [x] 2.7 Add keyboard/IME, focus restoration, accessibility metadata, localization, and disposal/unsubscribe characterization tests for first-slice candidates. (`MorphTypeAtomicLauncherTests.DoNotRefresh_RemainingSliceRestoresFocus_AfterRefreshRebuild` covers legacy refresh focus restoration; `PocLexEntrySliceTests` covers focused Unicode text entry plus stable automation metadata on the first-slice candidate controls; `LexOptionsDlgTests.UIModeControls_ReadDisplayTextFromResx` locks the UI-mode localization resources. Disposal/unsubscribe + legacy accessibility/focus-order remain in `DataTreeDisposalCharacterizationTests`.) - [x] 2.8 Add snapshot normalization rules so semantic baselines key on stable node IDs, class/flid/object binding, editor kind, writing-system metadata, ghost state, focus order, and accessibility identity instead of incidental layout noise. (Typed `ViewDefinitionModel.ToSnapshot` keys on stable IDs, field binding, editor classification, ws, visibility, expansion; ghost/a11y metadata deferred. `Src/Common/FwAvalonia/ViewDefinition`.) -- [ ] 2.9 Define the canonical Path 3 parity bundle for legacy baselines: semantic snapshot, matched WinForms screenshot(s), workflow/accessibility evidence, and a failure summary id shared across artifacts. +- [x] 2.9 Define the canonical Path 3 parity bundle for legacy baselines: semantic snapshot, matched WinForms screenshot(s), workflow/accessibility evidence, and a failure summary id shared across artifacts. (Canonical contract now requires shared `scenarioId`, `bundleId`, and `failureSummaryId`, plus a lane manifest that marks missing Avalonia lanes as pending instead of omitting the bundle.) +- [x] 2.10 Add executable wiring baselines for the global UI mode: setting/app-setting source, `PropertyTable` broadcast, live host refresh, and current-content reload behavior without manual `OnPropertyChanged(...)` test calls. (`LexOptionsDlgTests` cover settings-to-`PropertyTable` mirroring; `RecordEditViewSwitchTests.LexiconEditTool_SwitchesSurfaceStateToAvalonia_WhenUIModePropertyBroadcasts` covers the real broadcast path and live content-control update without a manual handler call.) +- [x] 2.11 Add coverage for every current `RecordEditView` consumer under both UI modes, including Lexicon, Grammar, Notebook, Lists, and Words paths, and assert the configured fallback or blocked behavior for non-migrated surfaces. (`RecordEditViewSwitchTests` now covers `lexiconEdit` as the supported Avalonia path plus representative non-migrated fallbacks for Grammar (`posEdit`), Notebook (`notebookEdit`), Lists (`domainTypeEdit`), and Words (`Analyses`).) +- [x] 2.12 Add localization coverage for product-facing UI mode labels/messages and Avalonia fallback text, while keeping automation selectors stable through nonlocalized IDs rather than localized labels. (`LexOptionsDlgTests.UIModeControls_ReadDisplayTextFromResx` locks UI-mode resources; `WinFormsUiaSmokeTests` rely on stable nonlocalized filter-combo IDs; `PocLexEntrySliceTests` lock stable Avalonia automation metadata for first-slice controls. Current non-migrated product fallback is the explicit legacy path, so no separate localized fallback message is surfaced there yet.) +- [ ] 2.13 Measure and record legacy Lexical Edit performance baselines while the characterization harness is warm: entry open, refresh-after-edit, scroll/expand latency, and typing latency on large fixtures at 100% and 150% DPI, with fixture ID, machine profile, command, and artifact path. Replace the provisional budgets in `region-manifest.md` §5 with these measured numbers so 7.7 enforces real targets instead of placeholders. ## 3. Refactor Seams First @@ -28,10 +39,15 @@ - [x] 3.2 Extract refresh coordination into a testable service or state object while preserving current behavior. (`RefreshCoordinator` pure model of the LT-22414 DoNotRefresh/RefreshPending gate, with tests; live wiring deferred.) - [x] 3.3 Put an `ILexicalEditorRegistry` boundary in front of `SliceFactory` so editor keys can resolve to legacy slices now and Avalonia editors later. (`LexicalEditorRegistry` with fallback-to-legacy + tests; live `SliceFactory` delegation deferred.) - [x] 3.4 Extract at least one launcher humble object path, using morph type swap as the first target. (`MorphTypeSwapLogic` mirrors `MorphTypeAtomicLauncher.IsStemType` and the stem/affix data-loss decision, with tests; launcher delegation deferred.) -- [ ] 3.5 Define host/surface interfaces around `RecordEditView`/`DataTree` initialization, focus, context menus, and view replacement. (Partial: `IRegionLifetime` defined; full init/focus/context-menu/replacement surface still to do.) +- [x] 3.5 Define host/surface interfaces around `RecordEditView`/`DataTree` initialization, focus, context menus, and view replacement. (`ILexicalEditSurface`/`ILexicalEditHost` in `FwAvalonia/Seams/IHostSurface.cs` capture init/show/hide/focus/context-menu/replace; `HostSurfaceContractTests` proves a fake host activates only the chosen surface. `RecordEditView` conforms via the surface-selection service and active-host contract; a full `RecordEditView : ILexicalEditHost` WinForms adapter remains optional follow-up.) - [x] 3.6 Extract edit-session and transaction seams for staged values, validation, cancellation, dirty state, undo/redo grouping, and LCModel commit behavior, following `avalonia-edit-sessions` and `avalonia-undo-redo`. (`IEditSession` + `PocEditSession` commit/cancel, with tests; LCModel-fenced impl deferred to the regional step.) - [x] 3.7 Extract UI scheduling, focus navigation, command routing, and region lifetime/disposal seams before introducing editable Avalonia controls, following `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime`. (`IUiScheduler`/`ImmediateUiScheduler` and `IRegionLifetime`/`RegionLifetime` implemented + tested; `IXCoreCommandBridge` command/focus routing is contract-only.) - [x] 3.8 Inventory dynamic editor strings and custom editor constructs (`custom`, `customwithparams`, `autocustom`, loader-based editors, fallback slices) with diagnostics requirements. (`EditorKindMap` classifies known/dynamic/obsolete/unknown faithfully to `SliceFactory`; importer raises per-node diagnostics, with tests.) +- [x] 3.9 Extract an explicit global surface-selection/wiring service that maps the app-wide UI mode to per-host behavior, so `RecordEditView` and later hosts do not infer product routing ad hoc from settings/property-table state. (`LexicalEditSurfaceSelectionService` returns a `SurfaceDecision` of `LegacyActive`/`SupportedAvalonia`/`ExplicitLegacyFallback`/`Blocked` with a reason; `RecordEditView.ResolveConfiguredLexicalEditSurface` now routes through it; `LexicalEditSurfaceSelectionServiceTests` cover each behavior.) +- [x] 3.10 Define and enforce the active-host contract for migrated regions: the visible Avalonia path SHALL NOT instantiate or drive hidden legacy `DataTree`/menu infrastructure except through explicitly approved baseline adapters used only for comparison or fallback. (`ActiveHostContract` makes the rule data + `AssertLegacyDataTreeDriveAllowed`; `RecordEditView` no longer initializes/drives the DataTree while Avalonia is active — `EnsureLegacySurfaceInitialized`/`DataTree.ShowObject`/`Reset` are skipped, record-bar update is surface-agnostic; `RecordEditViewActiveHostContractTests` proves a fresh New-mode load leaves `m_legacySurfaceInitialized == false` while the Avalonia surface is created; `ActiveHostContractTests` cover the contract logic.) +- [x] 3.11 Add a repeatable wiring review checklist for feature-flag and host changes: setting source, mediator/property-table notifications, content reload path, focus/command target routing, fallback behavior, preview-vs-product boundaries, and build/test graph coverage. (`wiring-review-checklist.md` operationalizes the `fieldworks-ui-wiring-review` skill for this change; attach a filled copy per PR that touches surface routing.) +- [ ] 3.12 Build the bidirectional selection bridge (coexistence, not throwaway): implement `IRecordNavigationContext` over the real xCore `RecordClerk`/`PropertyTable` "current record" broadcast so an active Avalonia surface both follows the broadcast and publishes its own selection back to it. Account for `RecordClerk`'s sponsor-based message routing and HVO/object lifetime differences; test against the real mediator path (extend the `RecordEditViewActiveHostContractTests`/`MockFwXWindow` harness), not simulated handler calls. +- [ ] 3.13 Decide cross-framework clipboard fidelity and build the shared clipboard seam: a serialized multi-writing-system/TsString FieldWorks clipboard format plus plain-text fallback that both native-Views surfaces and Avalonia surfaces read and write via the OS clipboard. Decide early which rich Views formats will NOT round-trip and document the user-visible behavior; this decision shapes the TsString serialization used by 6.1/6.2. ## 4. Typed View Definition and XML Import @@ -41,6 +57,10 @@ - [x] 4.4 Add unsupported-construct diagnostics with layout part and node path. (Diagnostics for dynamic/unknown/obsolete editors, unresolved parts, unknown container/content, each carrying a stable node path.) - [x] 4.5 Add cache key, invalidation, async compile, and cancellation tests. (`ViewDefinitionCacheKey` fingerprint, `ViewDefinitionCache`, `ViewDefinitionCompiler.CompileAsync` with `ViewDefinitionCompilerTests`.) - [x] 4.6 Ensure off-thread compilation uses immutable layout, metadata, writing-system, custom-field, and override snapshots rather than live WinForms controls, `PropertyTable`, or cache mutation state. (`ViewDefinitionSourceSnapshot` captures immutable XML source; `CompileAsync` runs the importer off-thread over that snapshot only.) +- [x] 4.7 Carry localization/resource-key metadata, accessibility identity, and product-vs-preview routing metadata through typed view-definition/Presentation IR for any node that can appear on a globally switchable surface. (`ViewNode` gains optional `LocalizationKey`, `AutomationId`, and `SurfaceRouting`; `XmlLayoutImporter` reads `localizationKey`/`labelId`, `automationId`, and `surface` attributes; `ToSnapshot` appends them only when present so legacy semantic baselines are unchanged; `ViewDefinitionMetadataTests` cover defaults, snapshot stability, and population.) +- [x] 4.8 Replace any product-facing lossy POC-only DTO mapping with typed-definition-backed region models before the global UI mode routes real screens through Avalonia. (`LexicalEditRegionModel`/`LexicalEditRegionMapper` project a typed `ViewDefinitionModel` + `IRegionValueProvider` into a value-bound region; `LexicalEditRegionView` renders it data-driven with stable automation ids; `RecordEditView` product route now calls `LexicalEditRegionBuilder.Build` + `ShowRegion` instead of `LexicalEditPocMapper`/`ShowEntry`. The lossy `LexicalEditPocMapper`/`PocEntryDto` are now preview-host only. First-slice definition is authored in the IR vocabulary; compiling it from the live layout inventory and LCModel-backed editing/write-back remain 6.x/7.x.) +- [ ] 4.9 Make importer coverage measurable instead of assumed: emit a diagnostic for every silently dropped layout construct and attribute — `if`/`ifnot` conditionals, `` (schema-driven custom-field generation), `$ws`/`$fieldName`/`{0}` parameter substitution, `menu`/`hotlinks` context-menu bindings, `style`/`css`, `before`/`after`/`sep` decoration, `collapsedLayout`, `showLabels`/`flowType`, numbering attributes, and `` — then run the importer over every shipped `.fwlayout`/`*Parts.xml` under `DistFiles/Language Explorer/Configuration/Parts/` and publish a coverage report (constructs handled vs dropped, per file). Current element-level diagnostics catch unknown content nodes but attribute drops are silent; real layouts (e.g. `LexEntry.fwlayout`: ~55 `` blocks) are dominated by the unhandled vocabulary, so this number gates 7.x scaling and 9.x retirement claims. +- [ ] 4.10 Compile the product first-slice definition from the live layout inventory via `ViewDefinitionCompiler` instead of the hand-authored `LexicalEditRegionBuilder.BuildFirstSliceDefinition()`, and source chooser options from LCModel instead of the hardcoded morph-type list (`stem`/`root`/`prefix`/`suffix` currently collapses phrase/clitic/infix/etc. to `stem` in `GetMorphTypeKey`). This is the step that makes Track B (typed IR) actually feed the product route end to end. ## 5. Graphite and Font Decommissioning @@ -64,6 +84,10 @@ - [ ] 6.7 Add styling/resource and density token gates for shared `FwAvalonia` resources before broad editor rollout. - [ ] 6.8 Make the first editable Avalonia slice satisfy `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local screen phase of `avalonia-command-focus` before expanding to more editors. - [ ] 6.9 Add control-level visual parity capture for Avalonia using Avalonia.Headless with Skia-enabled rendered-frame capture, and stamp stable `AutomationProperties.Name`/`AutomationProperties.AutomationId` on user-facing controls used in Path 3 bundles. +- [ ] 6.10 Wire the first product-facing Avalonia slice through real LCModel-backed edit-session, commit/cancel, validation, and undo/redo seams; detached DTO-only editing remains preview-only. +- [ ] 6.11 Move all product-facing Avalonia messages, UI mode labels, placeholder text, and unsupported-surface text to localized resources; keep `AutomationId` stable and nonlocalized while allowing localized names/tooltips. +- [ ] 6.12 Ensure every screen exposed by the global UI mode has a deliberate product behavior in Avalonia mode: supported surface, explicit legacy fallback, or resource-backed unsupported state with tests and diagnostics. +- [ ] 6.13 GATE — multi-writing-system text foundation: build the managed TsString-to-Avalonia text path (read and write-back), per-writing-system fonts/keyboards from project settings, IME composition behavior (compose, backspace-within-composition, commit), and RTL/bidi caret, selection, and mixed-direction rendering. Prove with Avalonia.Headless tests plus manual evidence on at least one RTL and one complex-script writing system. No region may claim editing parity before this gate passes: ~21 slice types are RootSite/Views-backed today, multi-WS string editing is the single most common Lexical Edit interaction, and no TsString path exists in `FwAvalonia` yet — this is the long pole, not cleanup work. ## 7. Tables, Slices, and Lexical Edit Migration @@ -75,6 +99,9 @@ - [ ] 7.6 Add a control-selection decision matrix for `TreeView`, `TreeDataGrid`, `ItemsRepeater`, and owned virtualized controls using density, virtualization, selection, accessibility, licensing/version, and multi-writing-system criteria. - [ ] 7.7 Add large-fixture performance budgets for open time, scroll/expand latency, typing latency, realized control count, memory, and cache invalidation. - [ ] 7.8 Produce Path 3 parity bundles for each first-slice and core parity fixture: WinForms visual evidence, Avalonia visual evidence, semantic snapshot, workflow/accessibility evidence, and an actionable failure summary. +- [ ] 7.9 Replace preview/prototype host wiring with typed-definition-backed product wiring before any migrated surface is advertised as product-ready under the global switch. +- [ ] 7.10 For each migrated region manifest, record whether non-migrated global consumers remain on explicit legacy fallback, and block ambiguous "best effort" routing through partial POC hosts. +- [ ] 7.11 Add UIA automation-tree parity evidence per migrated region: snapshot the legacy surface's UIA names/roles/order alongside the Avalonia surface's, and run an assistive-technology smoke (Tab/arrow traversal, menu and chooser launch) proving keyboard-only and screen-reader navigation reach equivalent targets. Wire this into the region manifest accessibility gate so it blocks default enablement, not just release notes. ## 8. C++ Viewing/Render Seam Decommissioning @@ -97,13 +124,16 @@ ## 10. Validation -- [ ] 10.1 Run targeted managed tests for changed areas using `./test.ps1` filters or relevant VS Code tasks. +- [ ] 10.1 Run targeted managed tests for changed areas using `./test.ps1` filters or relevant VS Code tasks, including Avalonia projects/tests through the normal repo test path when touched. - [ ] 10.2 Run render/parity baseline tests for affected surfaces. - [ ] 10.3 Run native viewing/render seam audit tests/instrumentation for any region claimed as migrated. - [ ] 10.4 Run Graphite/native-rendering default-path validation for any region proposed as default Avalonia UI. - [ ] 10.5 Run browser/PDF replacement validation for default-path XHTML preview, print, or PDF flows. -- [ ] 10.6 Run `./build.ps1` before implementation work is considered ready for review. +- [ ] 10.6 Run `./build.ps1` with normal Avalonia build coverage before implementation work is considered ready for review. - [ ] 10.7 Run `CI: Full local check` before commit/push. - [ ] 10.8 Verify every migrated-region manifest has passing evidence for native-call instrumentation, no unapproved Graphite/native-rendering default-path dependency, undo/redo, accessibility, localization, keyboard/IME, customer override fixtures, performance budgets, and rollback behavior. - [ ] 10.9 Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` instead of redefining those seams ad hoc during shell work. - [ ] 10.10 Verify every scenario used to claim visual fidelity has a complete Path 3 bundle and that each bundle explicitly classifies which lanes are proven (`semantic`, `visual`, `workflow/accessibility`, `performance`) and which remain pending. +- [ ] 10.11 Verify Avalonia projects and tests are exercised through the normal repo build/test graph rather than relying on branch-only or optional build lanes as the primary evidence path. +- [ ] 10.12 Run a dedicated product wiring review for every UI mode or host-routing change: branch-vs-main diff, product-vs-preview boundary, setting/broadcast path, host reload path, focus/command routing, fallback state, and no hidden active legacy rendering. +- [ ] 10.13 Run localization review for every product-facing Avalonia or UI-mode change: `.resx` coverage, Crowdin compatibility, stable automation IDs, localized user messages, and explicit evidence for any remaining prototype strings. diff --git a/openspec/changes/lexical-edit-avalonia-migration/wiring-review-checklist.md b/openspec/changes/lexical-edit-avalonia-migration/wiring-review-checklist.md new file mode 100644 index 0000000000..11439c144e --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/wiring-review-checklist.md @@ -0,0 +1,50 @@ +# UI Wiring Review Checklist (Task 3.11) + +A repeatable checklist for every feature-flag or host-routing change that affects which UI surface is +active. It operationalizes the `fieldworks-ui-wiring-review` skill for this change. Attach a filled copy +to each PR that touches surface selection, `PropertyTable`/mediator routing, or host replacement. + +## Scope + +- [ ] Reviewed against the branch-only diff (`main..HEAD`), not a calendar-day commit list. +- [ ] Listed **every** `RecordEditView` consumer / host affected (Lexicon, Grammar, Notebook, Lists, + Words), and each has a deliberate behavior under both UI modes: supported Avalonia, explicit + legacy fallback, or resource-backed blocked state. + +## Wiring path (trace end to end) + +- [ ] **Setting source** — where the mode is read (app setting / persisted `UIMode` preference). +- [ ] **Persisted state** — persistence flag set/cleared correctly; no accidental global persistence. +- [ ] **`PropertyTable` key** — `UIMode` (and `currentContentControl` for per-tool resolution). +- [ ] **Broadcast** — change is delivered via the real mediator/property broadcast, not a manual + `OnPropertyChanged(...)` call in production or tests. +- [ ] **Listener registration** — the host subscribes/unsubscribes symmetrically (no leak). +- [ ] **Resolution** — routed through `LexicalEditSurfaceSelectionService` (3.9), not ad-hoc reads of + settings/property-table state scattered in the host. +- [ ] **Host reload path** — current content is re-shown on switch without a tool reload. +- [ ] **Focus / command target routing** — active surface added to Ctrl+Tab/message targets; inactive + surface removed. +- [ ] **Save / `PrepareToGoAway()`** — routes to the active surface only. +- [ ] **Fallback / blocked state** — non-migrated hosts fall back explicitly. + +## Active-host contract (3.10) + +- [ ] The active Avalonia path does **not** instantiate or drive a hidden legacy `DataTree`/menu + infrastructure (no `EnsureLegacySurfaceInitialized` / `DataTree.ShowObject` while Avalonia is + active), except through an adapter explicitly declared in `ActiveHostContract.AllowedBaselineAdapters`. +- [ ] An audit test proves it (e.g. `RecordEditViewActiveHostContractTests`). + +## Product vs preview boundary + +- [ ] Product route uses a **typed-definition-backed region model** (`LexicalEditRegionModel`), not a + lossy `LexicalEditPocMapper` DTO or preview host code. +- [ ] Preview-only artifacts (`PocEntryDto`, `PocPreviewDataProvider`, sample data) are not on any + product route. +- [ ] Product-facing strings are localizable; remaining prototype strings are called out as gaps. +- [ ] Stable, nonlocalized `AutomationId`s on user-facing controls; localized names/tooltips allowed. + +## Build / test graph + +- [ ] Validated through the normal repo path (`./build.ps1`, `./test.ps1`) plus host-specific tests — + not a branch-only `-BuildAvalonia` lane as the primary evidence. +- [ ] Tests drive the real setting + broadcast path; none simulate wiring via direct handler calls. diff --git a/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md b/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md index 98d69b63e2..3d60b1e575 100644 --- a/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md +++ b/openspec/changes/lexical-edit-avalonia-poc-spike/spike-evidence.md @@ -71,13 +71,20 @@ The 20 passing tests cover: These require the full FieldWorks app to build and run, which is heavier than this isolated spike and was intentionally deferred to keep the default build safe: -1. **In-process embedding into `RecordEditView`** via `WinFormsAvaloniaControlHost` under the live - net48 message loop and DPI settings (task 2.3). The package-level feasibility is proven; the live - embedding is not. +1. ~~**In-process embedding into `RecordEditView`** via `WinFormsAvaloniaControlHost` under the live + net48 message loop and DPI settings (task 2.3).~~ **CLOSED (2026-06-09).** The live embedding now + exists in product code: `RecordEditView` routes the lexicon edit tool through + `LexicalEditSurfaceSelectionService` to an in-process Avalonia host (`PocWinFormsHostControl` / + `LexicalEditRegionView`) under the real net48 message loop, with preview-host UIA smoke tests and + `RecordEditViewActiveHostContractTests` driving the real mediator/idle path. Note: the embedding + was delivered through the lexical-edit program's region-model boundary, not the + `datatree-model-view-separation` route this report anticipated — see lexical-edit task 1.13 for + the roadmap reconciliation. 2. **DPI density measurement** at 100% and 150% and **side-by-side screenshots** of flag-off vs - flag-on in the running app (tasks 0.2, 4.2, 4.3). + flag-on in the running app (tasks 0.2, 4.2, 4.3). Still open. 3. **Avalonia.Headless render-frame** native/Graphite runtime assertion beyond the reference audit - (task 4.4 is covered at the reference level; a rendered-frame assertion is not added). + (task 4.4 is covered at the reference level; a rendered-frame assertion is not added). Still open; + tracked by lexical-edit tasks 6.9/8.4. ## Go / No-Go @@ -97,4 +104,5 @@ and gated by Gate 0. this spike's two-adapter flag. The first regional task is to embed the proven slice into `RecordEditView` behind the flag and capture the DPI density evidence. - Keep the spike projects isolated until Gate 0's live-embedding evidence is captured, then add them - to `FieldWorks.proj` and `FieldWorks.sln` per `avalonia.instructions.md`. + to `FieldWorks.proj` and `FieldWorks.sln` per `avalonia.instructions.md`. **(2026-06-09: the + live-embedding evidence now exists, so this integration is due — tracked as lexical-edit task 1.11.)** From 4330a6e8e5d5f0a0369582c9c4365abd54f2d558 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 10:41:09 -0400 Subject: [PATCH 03/14] IMplementation 1.0 Pull out Graphite decommissioning to another spec. Lexical Edit view almost there Fix right clicks - fix crashing First AI review Half implemented blockers --- Directory.Packages.props | 7 + FieldWorks.sln | 1752 ++++++++++++++++- .../Controls/DetailControls/DataTree.cs | 8 + .../DataTreeReshowTimingTests.cs | 90 + Src/Common/FieldWorks/App.config | 11 + Src/Common/FieldWorks/FieldWorks.cs | 19 + Src/Common/FwAvalonia/FwAvalonia.csproj | 27 +- Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 43 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 63 + .../BrowseAndCanonicalJsonTests.cs | 258 +++ .../EngineIsolationAuditTests.cs | 95 + .../FwAvaloniaTests/FwAvaloniaTests.csproj | 16 +- .../FwAvaloniaTests/FwClipboardSeamTests.cs | 42 + .../LayoutImportCoverageTests.cs | 831 ++++++++ .../LexicalEditFirstSliceTests.cs | 115 ++ .../FwAvaloniaTests/Path3BundleTests.cs | 103 + .../FwAvaloniaTests/RegionEditingTests.cs | 363 ++++ .../FwAvaloniaTests/RegionFocusMemoryTests.cs | 122 ++ .../FwAvaloniaTests/RegionMenuTests.cs | 281 +++ .../FwAvaloniaTests/RegionModelTests.cs | 4 +- .../RegionViewingParityTests.cs | 263 +++ .../FwAvalonia/FwAvaloniaTests/SeamTests.cs | 111 +- .../SurfaceAndHostContractTests.cs | 59 +- .../FwAvaloniaTests/TestAppBuilder.cs | 5 +- .../FwAvaloniaTests/TreeSpikeAndRtlTests.cs | 140 ++ .../VisualParityAndDensityTests.cs | 147 ++ Src/Common/FwAvalonia/Poc/PocApp.cs | 4 - Src/Common/FwAvalonia/Poc/PocDensity.cs | 23 + .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 117 +- .../FwAvalonia/Region/FwFieldControls.cs | 228 +++ .../FwAvalonia/Region/IRegionEditContext.cs | 52 + .../FwAvalonia/Region/LexicalBrowseView.cs | 165 ++ .../Region/LexicalEditFirstSlice.cs | 188 ++ .../Region/LexicalEditRegionMapper.cs | 27 +- .../Region/LexicalEditRegionModel.cs | 118 +- .../Region/LexicalEditRegionView.cs | 442 ++++- .../FwAvalonia/Region/RegionFocusMemory.cs | 87 + .../FwAvalonia/Region/RegionMenuFlyout.cs | 103 + .../FwAvalonia/Seams/ActiveHostContract.cs | 10 + .../FinalizerSafeSynchronizationContext.cs | 84 + Src/Common/FwAvalonia/Seams/FwDragDrop.cs | 60 + Src/Common/FwAvalonia/Seams/IFwClipboard.cs | 69 + Src/Common/FwAvalonia/Seams/IHostSurface.cs | 76 - Src/Common/FwAvalonia/Seams/ISeams.cs | 48 +- .../FwAvalonia/Seams/SeamImplementations.cs | 67 - .../ViewDefinition/DictionaryPartResolver.cs | 37 +- .../ViewDefinition/EditorKindMap.cs | 11 +- .../ViewDefinition/LayoutImportCoverage.cs | 311 +++ .../ViewDefinition/LayoutSourceLoader.cs | 114 ++ .../ViewDefinition/ViewDefinitionCompiler.cs | 17 +- .../ViewDefinitionJsonSerializer.cs | 201 ++ .../ViewDefinition/ViewDefinitionModel.cs | 226 ++- .../ViewDefinition/XmlLayoutImporter.cs | 419 +++- .../PreviewHostUiaTests.cs | 33 + .../FwUtilsTests/VersionInfoProviderTests.cs | 89 + Src/Common/FwUtils/VersionInfoProvider.cs | 62 +- Src/Common/SimpleRootSite/TsStringWrapper.cs | 31 +- Src/CommonAssemblyInfoTemplate.cs | 2 +- Src/XCore/xWindow.cs | 24 + Src/xWorks/AvaloniaCompanionSlices.cs | 150 ++ Src/xWorks/AvaloniaRegionRefreshController.cs | 199 ++ Src/xWorks/DTMenuHandler.cs | 5 +- Src/xWorks/FullEntryRegionComposer.cs | 1347 +++++++++++++ Src/xWorks/FwDragDropData.cs | 58 + Src/xWorks/FwTsStringClipboard.cs | 117 ++ Src/xWorks/LcmRegionEditSession.cs | 61 + Src/xWorks/LexicalEditPocMapper.cs | 103 - Src/xWorks/LexicalEditRegionBuilder.cs | 174 +- Src/xWorks/LexicalEditRegionEditContext.cs | 115 ++ Src/xWorks/RecordClerkNavigationContext.cs | 77 + Src/xWorks/RecordEditView.cs | 586 +++++- Src/xWorks/RegionEditContextBase.cs | 77 + Src/xWorks/RegionEditContextHolder.cs | 113 ++ Src/xWorks/XCoreMenuBridge.cs | 92 + .../FullEntryRegionReferenceChooserTests.cs | 306 +++ .../xWorksTests/FwTsStringClipboardTests.cs | 108 + .../xWorksTests/LexicalEditPocMapperTests.cs | 50 - .../LexicalEditRegionEditingTests.cs | 1234 ++++++++++++ .../xWorksTests/MessagesCompanionLaneTests.cs | 130 ++ .../RecordClerkNavigationContextTests.cs | 198 ++ .../RecordEditViewActiveHostContractTests.cs | 6 + .../RegionEditGuardAndSchedulingTests.cs | 313 +++ .../RegionEditSessionLifecycleTests.cs | 149 ++ .../avalonia-migration-roadmap/design.md | 7 +- .../.openspec.yaml | 2 + .../graphite-transition-support/design.md | 186 ++ .../graphite-transition-support/proposal.md | 83 + .../specs/graphite-transition-support/spec.md | 93 + .../graphite-transition-support/tasks.md | 35 + .../architecture-diagrams.md | 15 +- .../canonical-view-definition-design.md | 232 +++ .../context-menu-inventory.md | 87 + .../control-selection-matrix.md | 149 ++ .../lexical-edit-avalonia-migration/design.md | 17 +- .../dialog-ownership.md | 43 + .../gecko-pdf-audit.md | 153 ++ .../graphite-decommissioning.md | 10 + .../layout-import-coverage.md | 295 +++ .../localization-review.md | 164 ++ .../native-views-audit.md | 218 ++ .../proposal.md | 4 +- .../region-manifest.md | 68 +- .../seam-domain-comparison.md | 17 +- .../seam-recommendations.md | 23 +- .../lexical-edit-font-decommissioning/spec.md | 32 +- .../lexical-edit-avalonia-migration/tasks.md | 229 ++- .../xml-retirement-blockers.md | 368 ++++ .../xmlviews-table-semantics.md | 351 ++++ 108 files changed, 16402 insertions(+), 767 deletions(-) create mode 100644 Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeReshowTimingTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaStrings.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaStrings.resx create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/FwClipboardSeamTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditFirstSliceTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/Path3BundleTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionFocusMemoryTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionMenuTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionViewingParityTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/TreeSpikeAndRtlTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/VisualParityAndDensityTests.cs create mode 100644 Src/Common/FwAvalonia/Region/FwFieldControls.cs create mode 100644 Src/Common/FwAvalonia/Region/IRegionEditContext.cs create mode 100644 Src/Common/FwAvalonia/Region/LexicalBrowseView.cs create mode 100644 Src/Common/FwAvalonia/Region/LexicalEditFirstSlice.cs create mode 100644 Src/Common/FwAvalonia/Region/RegionFocusMemory.cs create mode 100644 Src/Common/FwAvalonia/Region/RegionMenuFlyout.cs create mode 100644 Src/Common/FwAvalonia/Seams/FinalizerSafeSynchronizationContext.cs create mode 100644 Src/Common/FwAvalonia/Seams/FwDragDrop.cs create mode 100644 Src/Common/FwAvalonia/Seams/IFwClipboard.cs delete mode 100644 Src/Common/FwAvalonia/Seams/IHostSurface.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/LayoutSourceLoader.cs create mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs create mode 100644 Src/Common/FwUtils/FwUtilsTests/VersionInfoProviderTests.cs create mode 100644 Src/xWorks/AvaloniaCompanionSlices.cs create mode 100644 Src/xWorks/AvaloniaRegionRefreshController.cs create mode 100644 Src/xWorks/FullEntryRegionComposer.cs create mode 100644 Src/xWorks/FwDragDropData.cs create mode 100644 Src/xWorks/FwTsStringClipboard.cs create mode 100644 Src/xWorks/LcmRegionEditSession.cs delete mode 100644 Src/xWorks/LexicalEditPocMapper.cs create mode 100644 Src/xWorks/LexicalEditRegionEditContext.cs create mode 100644 Src/xWorks/RecordClerkNavigationContext.cs create mode 100644 Src/xWorks/RegionEditContextBase.cs create mode 100644 Src/xWorks/RegionEditContextHolder.cs create mode 100644 Src/xWorks/XCoreMenuBridge.cs create mode 100644 Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs create mode 100644 Src/xWorks/xWorksTests/FwTsStringClipboardTests.cs delete mode 100644 Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs create mode 100644 Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs create mode 100644 Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs create mode 100644 Src/xWorks/xWorksTests/RecordClerkNavigationContextTests.cs create mode 100644 Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs create mode 100644 Src/xWorks/xWorksTests/RegionEditSessionLifecycleTests.cs create mode 100644 openspec/changes/graphite-transition-support/.openspec.yaml create mode 100644 openspec/changes/graphite-transition-support/design.md create mode 100644 openspec/changes/graphite-transition-support/proposal.md create mode 100644 openspec/changes/graphite-transition-support/specs/graphite-transition-support/spec.md create mode 100644 openspec/changes/graphite-transition-support/tasks.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/canonical-view-definition-design.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/context-menu-inventory.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/control-selection-matrix.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/gecko-pdf-audit.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/localization-review.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/native-views-audit.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/xmlviews-table-semantics.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 486223338c..066dc2e97a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -166,6 +166,10 @@ --> + + @@ -195,5 +199,8 @@ + + diff --git a/FieldWorks.sln b/FieldWorks.sln index aae0cc092f..16644e2075 100644 --- a/FieldWorks.sln +++ b/FieldWorks.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.14.36401.2 @@ -218,7 +219,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "xWorksTests", "Src\xWorks\x EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Generic", "Src\Generic\Generic.vcxproj", "{7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}" EndProject -Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "FwKernel", "Src\Kernel\Kernel.vcxproj", "{6396B488-4D34-48B2-8639-EEB90707405B}" +Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Kernel", "Src\Kernel\Kernel.vcxproj", "{6396B488-4D34-48B2-8639-EEB90707405B}" EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "views", "Src\views\views.vcxproj", "{C86CA2EB-81B5-4411-B5B7-E983314E02DA}" EndProject @@ -282,841 +283,2585 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "TestViews", "Src\views\Test EndProject Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "TestGeneric", "Src\Generic\Test\TestGeneric.vcxproj", "{C644C392-FB14-4DF1-9989-897E182D3849}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Common", "Common", "{BFF2DF6E-AA54-47C7-211F-79ACCBB4577C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvalonia", "Src\Common\FwAvalonia\FwAvalonia.csproj", "{E43B733E-FD49-4B8E-91FE-95DE8CCB2770}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaTests", "Src\Common\FwAvalonia\FwAvaloniaTests\FwAvaloniaTests.csproj", "{7422D0D6-724C-4A12-993B-055727523EC8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaPreviewHost", "Src\Common\FwAvaloniaPreviewHost\FwAvaloniaPreviewHost.csproj", "{EDD76559-F4AD-4841-9A26-B1EC3C6E232E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FwAvaloniaPreviewHostTests", "Src\Common\FwAvaloniaPreviewHost\FwAvaloniaPreviewHostTests\FwAvaloniaPreviewHostTests.csproj", "{CBF8161E-DCC7-48C2-A754-74F5EF144E1C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Bounds|x64 = Bounds|x64 + Bounds|Any CPU = Bounds|Any CPU + Bounds|x86 = Bounds|x86 Debug|x64 = Debug|x64 + Debug|Any CPU = Debug|Any CPU + Debug|x86 = Debug|x86 Release|x64 = Release|x64 + Release|Any CPU = Release|Any CPU + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|x64.ActiveCfg = Release|x64 {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|x64.Build.0 = Release|x64 + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Bounds|x86.Build.0 = Bounds|Any CPU {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|x64.ActiveCfg = Debug|x64 {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|x64.Build.0 = Debug|x64 + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Debug|x86.Build.0 = Debug|Any CPU {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|x64.ActiveCfg = Release|x64 {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|x64.Build.0 = Release|x64 + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|Any CPU.Build.0 = Release|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|x86.ActiveCfg = Release|Any CPU + {6E9C3A6D-5200-598B-A0DF-6AB5BAC33321}.Release|x86.Build.0 = Release|Any CPU {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|x64.ActiveCfg = Release|x64 {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|x64.Build.0 = Release|x64 + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Bounds|x86.Build.0 = Bounds|Any CPU {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|x64.ActiveCfg = Debug|x64 {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|x64.Build.0 = Debug|x64 + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|x86.ActiveCfg = Debug|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Debug|x86.Build.0 = Debug|Any CPU {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|x64.ActiveCfg = Release|x64 {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|x64.Build.0 = Release|x64 + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|Any CPU.Build.0 = Release|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|x86.ActiveCfg = Release|Any CPU + {7827DE67-1E76-5DFA-B3E7-122B2A5B2472}.Release|x86.Build.0 = Release|Any CPU {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|x64.ActiveCfg = Release|x64 {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|x64.Build.0 = Release|x64 + {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Bounds|x86.Build.0 = Bounds|Any CPU {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|x64.ActiveCfg = Debug|x64 {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|x64.Build.0 = Debug|x64 + {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|x86.ActiveCfg = Debug|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Debug|x86.Build.0 = Debug|Any CPU {EB470157-7A33-5263-951E-2190FC2AD626}.Release|x64.ActiveCfg = Release|x64 {EB470157-7A33-5263-951E-2190FC2AD626}.Release|x64.Build.0 = Release|x64 + {EB470157-7A33-5263-951E-2190FC2AD626}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Release|Any CPU.Build.0 = Release|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Release|x86.ActiveCfg = Release|Any CPU + {EB470157-7A33-5263-951E-2190FC2AD626}.Release|x86.Build.0 = Release|Any CPU {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|x64.ActiveCfg = Release|x64 {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|x64.Build.0 = Release|x64 + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Bounds|x86.Build.0 = Bounds|Any CPU {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|x64.ActiveCfg = Debug|x64 {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|x64.Build.0 = Debug|x64 + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|x86.ActiveCfg = Debug|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Debug|x86.Build.0 = Debug|Any CPU {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|x64.ActiveCfg = Release|x64 {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|x64.Build.0 = Release|x64 + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|Any CPU.Build.0 = Release|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|x86.ActiveCfg = Release|Any CPU + {B26CBC5A-711C-5EA4-A2AA-AAF81565CA34}.Release|x86.Build.0 = Release|Any CPU {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|x64.ActiveCfg = Release|x64 {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|x64.Build.0 = Release|x64 + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Bounds|x86.Build.0 = Bounds|Any CPU {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|x64.ActiveCfg = Debug|x64 {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|x64.Build.0 = Debug|x64 + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|x86.ActiveCfg = Debug|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Debug|x86.Build.0 = Debug|Any CPU {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|x64.ActiveCfg = Release|x64 {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|x64.Build.0 = Release|x64 + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|Any CPU.Build.0 = Release|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|x86.ActiveCfg = Release|Any CPU + {01C9D37F-BCFA-5353-A980-84EFD3821F8A}.Release|x86.Build.0 = Release|Any CPU {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|x64.ActiveCfg = Release|x64 {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|x64.Build.0 = Release|x64 + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Bounds|x86.Build.0 = Bounds|Any CPU {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|x64.ActiveCfg = Debug|x64 {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|x64.Build.0 = Debug|x64 + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|x86.ActiveCfg = Debug|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Debug|x86.Build.0 = Debug|Any CPU {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|x64.ActiveCfg = Release|x64 {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|x64.Build.0 = Release|x64 + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|Any CPU.Build.0 = Release|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|x86.ActiveCfg = Release|Any CPU + {762BD8EC-F9B2-5927-BC21-9D31D5A14C10}.Release|x86.Build.0 = Release|Any CPU {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|x64.ActiveCfg = Release|x64 {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|x64.Build.0 = Release|x64 + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Bounds|x86.Build.0 = Bounds|Any CPU {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|x64.ActiveCfg = Debug|x64 {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|x64.Build.0 = Debug|x64 + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|x86.ActiveCfg = Debug|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Debug|x86.Build.0 = Debug|Any CPU {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|x64.ActiveCfg = Release|x64 {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|x64.Build.0 = Release|x64 + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|Any CPU.Build.0 = Release|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|x86.ActiveCfg = Release|Any CPU + {43FEB32F-DF19-5622-AAF3-7A4CFE118D0F}.Release|x86.Build.0 = Release|Any CPU {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|x64.ActiveCfg = Release|x64 {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|x64.Build.0 = Release|x64 + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Bounds|x86.Build.0 = Bounds|Any CPU {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|x64.ActiveCfg = Debug|x64 {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|x64.Build.0 = Debug|x64 + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|x86.ActiveCfg = Debug|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Debug|x86.Build.0 = Debug|Any CPU {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x64.ActiveCfg = Release|x64 {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x64.Build.0 = Release|x64 + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|Any CPU.Build.0 = Release|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x86.ActiveCfg = Release|Any CPU + {36F2A7A6-C7F9-5D3D-87D7-B4C0D5C51C0E}.Release|x86.Build.0 = Release|Any CPU {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x64.ActiveCfg = Release|x64 {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x64.Build.0 = Release|x64 + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Bounds|x86.Build.0 = Bounds|Any CPU {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x64.ActiveCfg = Debug|x64 {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x64.Build.0 = Debug|x64 + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x86.ActiveCfg = Debug|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Debug|x86.Build.0 = Debug|Any CPU {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x64.ActiveCfg = Release|x64 {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x64.Build.0 = Release|x64 + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|Any CPU.Build.0 = Release|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x86.ActiveCfg = Release|Any CPU + {B53C715F-2F97-4E2B-9C10-5BFF1C04A446}.Release|x86.Build.0 = Release|Any CPU {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x64.ActiveCfg = Release|x64 {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x64.Build.0 = Release|x64 + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Bounds|x86.Build.0 = Bounds|Any CPU {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x64.ActiveCfg = Debug|x64 {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x64.Build.0 = Debug|x64 + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Debug|x86.Build.0 = Debug|Any CPU {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.ActiveCfg = Release|x64 {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x64.Build.0 = Release|x64 + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|Any CPU.Build.0 = Release|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x86.ActiveCfg = Release|Any CPU + {6F7A4D9C-5B44-4D0E-ABAA-8D6F38F30C6A}.Release|x86.Build.0 = Release|Any CPU {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.ActiveCfg = Release|x64 {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x64.Build.0 = Release|x64 + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Bounds|x86.Build.0 = Bounds|Any CPU {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.ActiveCfg = Debug|x64 {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x64.Build.0 = Debug|x64 + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x86.ActiveCfg = Debug|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Debug|x86.Build.0 = Debug|Any CPU {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.ActiveCfg = Release|x64 {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x64.Build.0 = Release|x64 + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|Any CPU.Build.0 = Release|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x86.ActiveCfg = Release|Any CPU + {5AF55AED-9E72-42CB-9A1E-C61AE6FE4613}.Release|x86.Build.0 = Release|Any CPU {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.ActiveCfg = Release|x64 {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x64.Build.0 = Release|x64 + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Bounds|x86.Build.0 = Bounds|Any CPU {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x64.ActiveCfg = Debug|x64 {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x64.Build.0 = Debug|x64 + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x86.ActiveCfg = Debug|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Debug|x86.Build.0 = Debug|Any CPU {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|x64.ActiveCfg = Release|x64 {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|x64.Build.0 = Release|x64 + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|Any CPU.Build.0 = Release|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|x86.ActiveCfg = Release|Any CPU + {A51BAFC3-1649-584D-8D25-101884EE9EAA}.Release|x86.Build.0 = Release|Any CPU {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|x64.ActiveCfg = Release|x64 {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|x64.Build.0 = Release|x64 + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Bounds|x86.Build.0 = Bounds|Any CPU {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|x64.ActiveCfg = Debug|x64 {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|x64.Build.0 = Debug|x64 + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Debug|x86.Build.0 = Debug|Any CPU {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|x64.ActiveCfg = Release|x64 {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|x64.Build.0 = Release|x64 + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|Any CPU.Build.0 = Release|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|x86.ActiveCfg = Release|Any CPU + {1CE6483D-5D10-51AD-B2A7-FD7F82CCBAB2}.Release|x86.Build.0 = Release|Any CPU {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|x64.ActiveCfg = Release|x64 {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|x64.Build.0 = Release|x64 + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Bounds|x86.Build.0 = Bounds|Any CPU {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|x64.ActiveCfg = Debug|x64 {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|x64.Build.0 = Debug|x64 + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Debug|x86.Build.0 = Debug|Any CPU {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|x64.ActiveCfg = Release|x64 {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|x64.Build.0 = Release|x64 + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|Any CPU.Build.0 = Release|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|x86.ActiveCfg = Release|Any CPU + {D826C3DF-3501-5F31-BC84-24493A500F9D}.Release|x86.Build.0 = Release|Any CPU {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|x64.ActiveCfg = Release|x64 {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|x64.Build.0 = Release|x64 + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Bounds|x86.Build.0 = Bounds|Any CPU {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|x64.ActiveCfg = Debug|x64 {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|x64.Build.0 = Debug|x64 + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|x86.ActiveCfg = Debug|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Debug|x86.Build.0 = Debug|Any CPU {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|x64.ActiveCfg = Release|x64 {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|x64.Build.0 = Release|x64 + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|Any CPU.Build.0 = Release|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|x86.ActiveCfg = Release|Any CPU + {33123A2A-FD82-5134-B385-ADAC0A433B85}.Release|x86.Build.0 = Release|Any CPU {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|x64.ActiveCfg = Release|x64 {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|x64.Build.0 = Release|x64 + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Bounds|x86.Build.0 = Bounds|Any CPU {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|x64.ActiveCfg = Debug|x64 {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|x64.Build.0 = Debug|x64 + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Debug|x86.Build.0 = Debug|Any CPU {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|x64.ActiveCfg = Release|x64 {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|x64.Build.0 = Release|x64 + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|Any CPU.Build.0 = Release|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|x86.ActiveCfg = Release|Any CPU + {5DF15966-BF60-5D21-BDE3-301BB1D4AB3B}.Release|x86.Build.0 = Release|Any CPU {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|x64.ActiveCfg = Release|x64 {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|x64.Build.0 = Release|x64 + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Bounds|x86.Build.0 = Bounds|Any CPU {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|x64.ActiveCfg = Debug|x64 {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|x64.Build.0 = Debug|x64 + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|x86.ActiveCfg = Debug|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Debug|x86.Build.0 = Debug|Any CPU {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|x64.ActiveCfg = Release|x64 {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|x64.Build.0 = Release|x64 + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|Any CPU.Build.0 = Release|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|x86.ActiveCfg = Release|Any CPU + {DCA3866E-E101-5BBC-9E35-60E632A4EF24}.Release|x86.Build.0 = Release|Any CPU {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|x64.ActiveCfg = Release|x64 {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|x64.Build.0 = Release|x64 + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Bounds|x86.Build.0 = Bounds|Any CPU {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|x64.ActiveCfg = Debug|x64 {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|x64.Build.0 = Debug|x64 + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|x86.ActiveCfg = Debug|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Debug|x86.Build.0 = Debug|Any CPU {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|x64.ActiveCfg = Release|x64 {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|x64.Build.0 = Release|x64 + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|Any CPU.Build.0 = Release|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|x86.ActiveCfg = Release|Any CPU + {9C375199-FB95-5FB0-A5F3-B1E68C447C49}.Release|x86.Build.0 = Release|Any CPU {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|x64.ActiveCfg = Release|x64 {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|x64.Build.0 = Release|x64 + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Bounds|x86.Build.0 = Bounds|Any CPU {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|x64.ActiveCfg = Debug|x64 {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|x64.Build.0 = Debug|x64 + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Debug|x86.Build.0 = Debug|Any CPU {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|x64.ActiveCfg = Release|x64 {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|x64.Build.0 = Release|x64 + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|Any CPU.Build.0 = Release|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|x86.ActiveCfg = Release|Any CPU + {D7281406-A9A3-5B80-95CB-23D223A0FD2D}.Release|x86.Build.0 = Release|Any CPU {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|x64.ActiveCfg = Release|x64 {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|x64.Build.0 = Release|x64 + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Bounds|x86.Build.0 = Bounds|Any CPU {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|x64.ActiveCfg = Debug|x64 {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|x64.Build.0 = Debug|x64 + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Debug|x86.Build.0 = Debug|Any CPU {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|x64.ActiveCfg = Release|x64 {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|x64.Build.0 = Release|x64 + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|Any CPU.Build.0 = Release|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|x86.ActiveCfg = Release|Any CPU + {E6B2CDCC-E016-5328-AA87-BC095712FDE6}.Release|x86.Build.0 = Release|Any CPU {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|x64.ActiveCfg = Release|x64 {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|x64.Build.0 = Release|x64 + {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Bounds|x86.Build.0 = Bounds|Any CPU {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|x64.ActiveCfg = Debug|x64 {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|x64.Build.0 = Debug|x64 + {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|x86.ActiveCfg = Debug|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Debug|x86.Build.0 = Debug|Any CPU {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|x64.ActiveCfg = Release|x64 {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|x64.Build.0 = Release|x64 + {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|Any CPU.Build.0 = Release|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|x86.ActiveCfg = Release|Any CPU + {AA147037-F6BB-5556-858E-FC03DE028A37}.Release|x86.Build.0 = Release|Any CPU {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|x64.ActiveCfg = Release|x64 {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|x64.Build.0 = Release|x64 + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Bounds|x86.Build.0 = Bounds|Any CPU {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|x64.ActiveCfg = Debug|x64 {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|x64.Build.0 = Debug|x64 + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|x86.ActiveCfg = Debug|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Debug|x86.Build.0 = Debug|Any CPU {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|x64.ActiveCfg = Release|x64 {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|x64.Build.0 = Release|x64 + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|Any CPU.Build.0 = Release|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|x86.ActiveCfg = Release|Any CPU + {BC6E6932-35C6-55F7-8638-89F6C7DCA43A}.Release|x86.Build.0 = Release|Any CPU {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|x64.ActiveCfg = Release|x64 {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|x64.Build.0 = Release|x64 + {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Bounds|x86.Build.0 = Bounds|Any CPU {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|x64.ActiveCfg = Debug|x64 {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|x64.Build.0 = Debug|x64 + {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|x86.ActiveCfg = Debug|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Debug|x86.Build.0 = Debug|Any CPU {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|x64.ActiveCfg = Release|x64 {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|x64.Build.0 = Release|x64 + {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|Any CPU.Build.0 = Release|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|x86.ActiveCfg = Release|Any CPU + {221A2FA1-1710-5537-A125-5BE856B949CC}.Release|x86.Build.0 = Release|Any CPU {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|x64.ActiveCfg = Release|x64 {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|x64.Build.0 = Release|x64 + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Bounds|x86.Build.0 = Bounds|Any CPU {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|x64.ActiveCfg = Debug|x64 {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|x64.Build.0 = Debug|x64 + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Debug|x86.Build.0 = Debug|Any CPU {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|x64.ActiveCfg = Release|x64 {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|x64.Build.0 = Release|x64 + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|Any CPU.Build.0 = Release|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|x86.ActiveCfg = Release|Any CPU + {B9116D9B-CEC2-5917-A04D-8DDAEF5FA943}.Release|x86.Build.0 = Release|Any CPU {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|x64.ActiveCfg = Release|x64 {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|x64.Build.0 = Release|x64 + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Bounds|x86.Build.0 = Bounds|Any CPU {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|x64.ActiveCfg = Debug|x64 {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|x64.Build.0 = Debug|x64 + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|x86.ActiveCfg = Debug|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Debug|x86.Build.0 = Debug|Any CPU {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|x64.ActiveCfg = Release|x64 {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|x64.Build.0 = Release|x64 + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|Any CPU.Build.0 = Release|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|x86.ActiveCfg = Release|Any CPU + {016A743C-BD3C-523B-B5BC-E3791D3C49E3}.Release|x86.Build.0 = Release|Any CPU {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|x64.ActiveCfg = Release|x64 {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|x64.Build.0 = Release|x64 + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Bounds|x86.Build.0 = Bounds|Any CPU {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|x64.ActiveCfg = Debug|x64 {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|x64.Build.0 = Debug|x64 + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Debug|x86.Build.0 = Debug|Any CPU {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|x64.ActiveCfg = Release|x64 {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|x64.Build.0 = Release|x64 + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|Any CPU.Build.0 = Release|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|x86.ActiveCfg = Release|Any CPU + {3B8923F8-CA27-5B0C-ABA8-735CE02B6A6D}.Release|x86.Build.0 = Release|Any CPU {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|x64.ActiveCfg = Release|x64 {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|x64.Build.0 = Release|x64 + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Bounds|x86.Build.0 = Bounds|Any CPU {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|x64.ActiveCfg = Debug|x64 {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|x64.Build.0 = Debug|x64 + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Debug|x86.Build.0 = Debug|Any CPU {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|x64.ActiveCfg = Release|x64 {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|x64.Build.0 = Release|x64 + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|Any CPU.Build.0 = Release|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|x86.ActiveCfg = Release|Any CPU + {CFCBBE66-B323-53E4-93F1-5CFB00CE02E9}.Release|x86.Build.0 = Release|Any CPU {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|x64.ActiveCfg = Release|x64 {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|x64.Build.0 = Release|x64 + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Bounds|x86.Build.0 = Bounds|Any CPU {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|x64.ActiveCfg = Debug|x64 {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|x64.Build.0 = Debug|x64 + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Debug|x86.Build.0 = Debug|Any CPU {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|x64.ActiveCfg = Release|x64 {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|x64.Build.0 = Release|x64 + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|Any CPU.Build.0 = Release|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|x86.ActiveCfg = Release|Any CPU + {D5BC4B46-5126-563F-9537-B8FA5F573E55}.Release|x86.Build.0 = Release|Any CPU {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|x64.ActiveCfg = Release|x64 {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|x64.Build.0 = Release|x64 + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Bounds|x86.Build.0 = Bounds|Any CPU {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|x64.ActiveCfg = Debug|x64 {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|x64.Build.0 = Debug|x64 + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|x86.ActiveCfg = Debug|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Debug|x86.Build.0 = Debug|Any CPU {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|x64.ActiveCfg = Release|x64 {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|x64.Build.0 = Release|x64 + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|Any CPU.Build.0 = Release|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|x86.ActiveCfg = Release|Any CPU + {6E80DBC7-731A-5918-8767-9A402EC483E6}.Release|x86.Build.0 = Release|Any CPU {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|x64.ActiveCfg = Release|x64 {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|x64.Build.0 = Release|x64 + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Bounds|x86.Build.0 = Bounds|Any CPU {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|x64.ActiveCfg = Debug|x64 {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|x64.Build.0 = Debug|x64 + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|x86.ActiveCfg = Debug|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Debug|x86.Build.0 = Debug|Any CPU {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|x64.ActiveCfg = Release|x64 {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|x64.Build.0 = Release|x64 + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|Any CPU.Build.0 = Release|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|x86.ActiveCfg = Release|Any CPU + {1EF0C15D-DF42-5457-841A-2F220B77304D}.Release|x86.Build.0 = Release|Any CPU {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|x64.ActiveCfg = Release|x64 {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|x64.Build.0 = Release|x64 + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Bounds|x86.Build.0 = Bounds|Any CPU {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|x64.ActiveCfg = Debug|x64 {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|x64.Build.0 = Debug|x64 + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|x86.ActiveCfg = Debug|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Debug|x86.Build.0 = Debug|Any CPU {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|x64.ActiveCfg = Release|x64 {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|x64.Build.0 = Release|x64 + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|Any CPU.Build.0 = Release|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|x86.ActiveCfg = Release|Any CPU + {28A7428D-3BA0-576C-A7B6-BA998439A036}.Release|x86.Build.0 = Release|Any CPU {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|x64.ActiveCfg = Release|x64 {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|x64.Build.0 = Release|x64 + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Bounds|x86.Build.0 = Bounds|Any CPU {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|x64.ActiveCfg = Debug|x64 {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|x64.Build.0 = Debug|x64 + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|x86.ActiveCfg = Debug|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Debug|x86.Build.0 = Debug|Any CPU {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|x64.ActiveCfg = Release|x64 {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|x64.Build.0 = Release|x64 + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|Any CPU.Build.0 = Release|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|x86.ActiveCfg = Release|Any CPU + {74AEB3F2-4C17-5196-AC93-C3B59EAB4C81}.Release|x86.Build.0 = Release|Any CPU {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|x64.ActiveCfg = Release|x64 {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|x64.Build.0 = Release|x64 + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Bounds|x86.Build.0 = Bounds|Any CPU {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|x64.ActiveCfg = Debug|x64 {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|x64.Build.0 = Debug|x64 + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|x86.ActiveCfg = Debug|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Debug|x86.Build.0 = Debug|Any CPU {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|x64.ActiveCfg = Release|x64 {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|x64.Build.0 = Release|x64 + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|Any CPU.Build.0 = Release|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|x86.ActiveCfg = Release|Any CPU + {5E16031F-2584-55B4-86B8-B42D7EEE8F25}.Release|x86.Build.0 = Release|Any CPU {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|x64.ActiveCfg = Release|x64 {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|x64.Build.0 = Release|x64 + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Bounds|x86.Build.0 = Bounds|Any CPU {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|x64.ActiveCfg = Debug|x64 {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|x64.Build.0 = Debug|x64 + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|x86.ActiveCfg = Debug|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Debug|x86.Build.0 = Debug|Any CPU {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|x64.ActiveCfg = Release|x64 {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|x64.Build.0 = Release|x64 + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|Any CPU.Build.0 = Release|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|x86.ActiveCfg = Release|Any CPU + {B46A3242-AAB2-5984-9F88-C65B7537D558}.Release|x86.Build.0 = Release|Any CPU {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|x64.ActiveCfg = Release|x64 {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|x64.Build.0 = Release|x64 + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Bounds|x86.Build.0 = Bounds|Any CPU {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|x64.ActiveCfg = Debug|x64 {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|x64.Build.0 = Debug|x64 + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|Any CPU.Build.0 = Debug|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|x86.ActiveCfg = Debug|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Debug|x86.Build.0 = Debug|Any CPU {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|x64.ActiveCfg = Release|x64 {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|x64.Build.0 = Release|x64 + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|Any CPU.ActiveCfg = Release|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|Any CPU.Build.0 = Release|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|x86.ActiveCfg = Release|Any CPU + {40A22FC7-C3FD-5C1B-9E5D-82A7C649F311}.Release|x86.Build.0 = Release|Any CPU {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|x64.ActiveCfg = Release|x64 {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|x64.Build.0 = Release|x64 + {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Bounds|x86.Build.0 = Bounds|Any CPU {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|x64.ActiveCfg = Debug|x64 {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|x64.Build.0 = Debug|x64 + {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Debug|x86.Build.0 = Debug|Any CPU {FE438201-74A1-5236-AE07-E502B853EA18}.Release|x64.ActiveCfg = Release|x64 {FE438201-74A1-5236-AE07-E502B853EA18}.Release|x64.Build.0 = Release|x64 + {FE438201-74A1-5236-AE07-E502B853EA18}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Release|Any CPU.Build.0 = Release|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Release|x86.ActiveCfg = Release|Any CPU + {FE438201-74A1-5236-AE07-E502B853EA18}.Release|x86.Build.0 = Release|Any CPU {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|x64.ActiveCfg = Release|x64 {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|x64.Build.0 = Release|x64 + {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Bounds|x86.Build.0 = Bounds|Any CPU {C7533C60-BF48-5844-8220-A488387AC016}.Debug|x64.ActiveCfg = Debug|x64 {C7533C60-BF48-5844-8220-A488387AC016}.Debug|x64.Build.0 = Debug|x64 + {C7533C60-BF48-5844-8220-A488387AC016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Debug|x86.ActiveCfg = Debug|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Debug|x86.Build.0 = Debug|Any CPU {C7533C60-BF48-5844-8220-A488387AC016}.Release|x64.ActiveCfg = Release|x64 {C7533C60-BF48-5844-8220-A488387AC016}.Release|x64.Build.0 = Release|x64 + {C7533C60-BF48-5844-8220-A488387AC016}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Release|Any CPU.Build.0 = Release|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Release|x86.ActiveCfg = Release|Any CPU + {C7533C60-BF48-5844-8220-A488387AC016}.Release|x86.Build.0 = Release|Any CPU {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|x64.ActiveCfg = Release|x64 {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|x64.Build.0 = Release|x64 + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Bounds|x86.Build.0 = Bounds|Any CPU {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|x64.ActiveCfg = Debug|x64 {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|x64.Build.0 = Debug|x64 + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Debug|x86.Build.0 = Debug|Any CPU {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|x64.ActiveCfg = Release|x64 {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|x64.Build.0 = Release|x64 + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|Any CPU.Build.0 = Release|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|x86.ActiveCfg = Release|Any CPU + {DA4DE504-7FAF-5BEF-8B4E-395D24CB6CB4}.Release|x86.Build.0 = Release|Any CPU {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|x64.ActiveCfg = Release|x64 {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|x64.Build.0 = Release|x64 + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Bounds|x86.Build.0 = Bounds|Any CPU {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|x64.ActiveCfg = Debug|x64 {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|x64.Build.0 = Debug|x64 + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|x86.ActiveCfg = Debug|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Debug|x86.Build.0 = Debug|Any CPU {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|x64.ActiveCfg = Release|x64 {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|x64.Build.0 = Release|x64 + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|Any CPU.Build.0 = Release|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|x86.ActiveCfg = Release|Any CPU + {A39B87BF-6846-559A-A01F-6251A0FE856E}.Release|x86.Build.0 = Release|Any CPU {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|x64.ActiveCfg = Release|x64 {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|x64.Build.0 = Release|x64 + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Bounds|x86.Build.0 = Bounds|Any CPU {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|x64.ActiveCfg = Debug|x64 {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|x64.Build.0 = Debug|x64 + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|x86.ActiveCfg = Debug|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Debug|x86.Build.0 = Debug|Any CPU {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|x64.ActiveCfg = Release|x64 {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|x64.Build.0 = Release|x64 + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|Any CPU.Build.0 = Release|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|x86.ActiveCfg = Release|Any CPU + {DBB982C6-E9E4-5535-ADC2-D0BA1E18F66F}.Release|x86.Build.0 = Release|Any CPU {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|x64.ActiveCfg = Release|x64 {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|x64.Build.0 = Release|x64 + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Bounds|x86.Build.0 = Bounds|Any CPU {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|x64.ActiveCfg = Debug|x64 {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|x64.Build.0 = Debug|x64 + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|x86.ActiveCfg = Debug|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Debug|x86.Build.0 = Debug|Any CPU {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|x64.ActiveCfg = Release|x64 {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|x64.Build.0 = Release|x64 + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|Any CPU.Build.0 = Release|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|x86.ActiveCfg = Release|Any CPU + {3B5B2AE4-53B3-5021-B5CA-15BC94EAB282}.Release|x86.Build.0 = Release|Any CPU {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|x64.ActiveCfg = Release|x64 {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|x64.Build.0 = Release|x64 + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Bounds|x86.Build.0 = Bounds|Any CPU {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|x64.ActiveCfg = Debug|x64 {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|x64.Build.0 = Debug|x64 + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|x86.ActiveCfg = Debug|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Debug|x86.Build.0 = Debug|Any CPU {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|x64.ActiveCfg = Release|x64 {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|x64.Build.0 = Release|x64 + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|Any CPU.Build.0 = Release|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|x86.ActiveCfg = Release|Any CPU + {644A443A-1066-57D2-9DFA-35CD9E9A46BE}.Release|x86.Build.0 = Release|Any CPU {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|x64.ActiveCfg = Release|x64 {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|x64.Build.0 = Release|x64 + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Bounds|x86.Build.0 = Bounds|Any CPU {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|x64.ActiveCfg = Debug|x64 {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|x64.Build.0 = Debug|x64 + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|x86.ActiveCfg = Debug|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Debug|x86.Build.0 = Debug|Any CPU {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|x64.ActiveCfg = Release|x64 {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|x64.Build.0 = Release|x64 + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|Any CPU.Build.0 = Release|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|x86.ActiveCfg = Release|Any CPU + {ABC70BB4-125D-54DD-B962-6131F490AB10}.Release|x86.Build.0 = Release|Any CPU {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|x64.ActiveCfg = Release|x64 {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|x64.Build.0 = Release|x64 + {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Bounds|x86.Build.0 = Bounds|Any CPU {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|x64.ActiveCfg = Debug|x64 {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|x64.Build.0 = Debug|x64 + {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|x86.ActiveCfg = Debug|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Debug|x86.Build.0 = Debug|Any CPU {6DA137DD-449E-57F1-8489-686CC307A561}.Release|x64.ActiveCfg = Release|x64 {6DA137DD-449E-57F1-8489-686CC307A561}.Release|x64.Build.0 = Release|x64 + {6DA137DD-449E-57F1-8489-686CC307A561}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Release|Any CPU.Build.0 = Release|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Release|x86.ActiveCfg = Release|Any CPU + {6DA137DD-449E-57F1-8489-686CC307A561}.Release|x86.Build.0 = Release|Any CPU {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|x64.ActiveCfg = Release|x64 {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|x64.Build.0 = Release|x64 + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Bounds|x86.Build.0 = Bounds|Any CPU {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|x64.ActiveCfg = Debug|x64 {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|x64.Build.0 = Debug|x64 + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Debug|x86.Build.0 = Debug|Any CPU {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|x64.ActiveCfg = Release|x64 {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|x64.Build.0 = Release|x64 + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|Any CPU.Build.0 = Release|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|x86.ActiveCfg = Release|Any CPU + {A2FDE99A-204A-5C10-995F-FD56039385C8}.Release|x86.Build.0 = Release|Any CPU {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|x64.ActiveCfg = Release|x64 {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|x64.Build.0 = Release|x64 + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Bounds|x86.Build.0 = Bounds|Any CPU {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|x64.ActiveCfg = Debug|x64 {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|x64.Build.0 = Debug|x64 + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|x86.ActiveCfg = Debug|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Debug|x86.Build.0 = Debug|Any CPU {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|x64.ActiveCfg = Release|x64 {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|x64.Build.0 = Release|x64 + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|Any CPU.Build.0 = Release|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|x86.ActiveCfg = Release|Any CPU + {43D44B32-899D-511D-9CF6-18CF7D3844CF}.Release|x86.Build.0 = Release|Any CPU {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|x64.ActiveCfg = Release|x64 {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|x64.Build.0 = Release|x64 + {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Bounds|x86.Build.0 = Bounds|Any CPU {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|x64.ActiveCfg = Debug|x64 {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|x64.Build.0 = Debug|x64 + {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|x86.ActiveCfg = Debug|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Debug|x86.Build.0 = Debug|Any CPU {1F87EA7A-211A-562D-95ED-00F935966948}.Release|x64.ActiveCfg = Release|x64 {1F87EA7A-211A-562D-95ED-00F935966948}.Release|x64.Build.0 = Release|x64 + {1F87EA7A-211A-562D-95ED-00F935966948}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Release|Any CPU.Build.0 = Release|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Release|x86.ActiveCfg = Release|Any CPU + {1F87EA7A-211A-562D-95ED-00F935966948}.Release|x86.Build.0 = Release|Any CPU {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|x64.ActiveCfg = Release|x64 {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|x64.Build.0 = Release|x64 + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Bounds|x86.Build.0 = Bounds|Any CPU {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|x64.ActiveCfg = Debug|x64 {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|x64.Build.0 = Debug|x64 + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|x86.ActiveCfg = Debug|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Debug|x86.Build.0 = Debug|Any CPU {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|x64.ActiveCfg = Release|x64 {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|x64.Build.0 = Release|x64 + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|Any CPU.Build.0 = Release|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|x86.ActiveCfg = Release|Any CPU + {6F79E30E-34D8-5938-B8C4-7B0FA9FDB5A6}.Release|x86.Build.0 = Release|Any CPU {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|x64.ActiveCfg = Release|x64 {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|x64.Build.0 = Release|x64 + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Bounds|x86.Build.0 = Bounds|Any CPU {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|x64.ActiveCfg = Debug|x64 {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|x64.Build.0 = Debug|x64 + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|x86.ActiveCfg = Debug|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Debug|x86.Build.0 = Debug|Any CPU {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|x64.ActiveCfg = Release|x64 {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|x64.Build.0 = Release|x64 + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|Any CPU.Build.0 = Release|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|x86.ActiveCfg = Release|Any CPU + {0434B036-FB8A-58B1-A075-B3D2D94BF492}.Release|x86.Build.0 = Release|Any CPU {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|x64.ActiveCfg = Release|x64 {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|x64.Build.0 = Release|x64 + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Bounds|x86.Build.0 = Bounds|Any CPU {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|x64.ActiveCfg = Debug|x64 {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|x64.Build.0 = Debug|x64 + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|x86.ActiveCfg = Debug|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Debug|x86.Build.0 = Debug|Any CPU {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|x64.ActiveCfg = Release|x64 {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|x64.Build.0 = Release|x64 + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|Any CPU.Build.0 = Release|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|x86.ActiveCfg = Release|Any CPU + {FFD4329F-ED9E-5EB6-BFEE-EE24E3759EA9}.Release|x86.Build.0 = Release|Any CPU {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|x64.ActiveCfg = Release|x64 {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|x64.Build.0 = Release|x64 + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Bounds|x86.Build.0 = Bounds|Any CPU {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|x64.ActiveCfg = Debug|x64 {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|x64.Build.0 = Debug|x64 + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|x86.ActiveCfg = Debug|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Debug|x86.Build.0 = Debug|Any CPU {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|x64.ActiveCfg = Release|x64 {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|x64.Build.0 = Release|x64 + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|Any CPU.Build.0 = Release|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|x86.ActiveCfg = Release|Any CPU + {3C904B25-FE98-55A8-A9AB-2CBA065AE297}.Release|x86.Build.0 = Release|Any CPU {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|x64.ActiveCfg = Release|x64 {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|x64.Build.0 = Release|x64 + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Bounds|x86.Build.0 = Bounds|Any CPU {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|x64.ActiveCfg = Debug|x64 {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|x64.Build.0 = Debug|x64 + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|x86.ActiveCfg = Debug|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Debug|x86.Build.0 = Debug|Any CPU {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|x64.ActiveCfg = Release|x64 {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|x64.Build.0 = Release|x64 + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|Any CPU.Build.0 = Release|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|x86.ActiveCfg = Release|Any CPU + {44E4C722-DCE1-5A8A-A586-81D329771F66}.Release|x86.Build.0 = Release|Any CPU {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|x64.ActiveCfg = Release|x64 {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|x64.Build.0 = Release|x64 + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Bounds|x86.Build.0 = Bounds|Any CPU {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|x64.ActiveCfg = Debug|x64 {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|x64.Build.0 = Debug|x64 + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Debug|x86.Build.0 = Debug|Any CPU {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|x64.ActiveCfg = Release|x64 {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|x64.Build.0 = Release|x64 + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|Any CPU.Build.0 = Release|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|x86.ActiveCfg = Release|Any CPU + {D7A0A7EA-6C5A-5953-862B-0CF3B779C34F}.Release|x86.Build.0 = Release|Any CPU {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|x64.ActiveCfg = Release|x64 {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|x64.Build.0 = Release|x64 + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Bounds|x86.Build.0 = Bounds|Any CPU {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|x64.ActiveCfg = Debug|x64 {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|x64.Build.0 = Debug|x64 + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|x86.ActiveCfg = Debug|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Debug|x86.Build.0 = Debug|Any CPU {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|x64.ActiveCfg = Release|x64 {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|x64.Build.0 = Release|x64 + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|Any CPU.Build.0 = Release|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|x86.ActiveCfg = Release|Any CPU + {1E4C57D6-BB15-56CD-A901-6EC5C0835FBE}.Release|x86.Build.0 = Release|Any CPU {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|x64.ActiveCfg = Release|x64 {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|x64.Build.0 = Release|x64 + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Bounds|x86.Build.0 = Bounds|Any CPU {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|x64.ActiveCfg = Debug|x64 {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|x64.Build.0 = Debug|x64 + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|x86.ActiveCfg = Debug|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Debug|x86.Build.0 = Debug|Any CPU {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|x64.ActiveCfg = Release|x64 {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|x64.Build.0 = Release|x64 + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|Any CPU.Build.0 = Release|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|x86.ActiveCfg = Release|Any CPU + {78FB823E-35FE-5D1D-B44D-17C22FDF6003}.Release|x86.Build.0 = Release|Any CPU {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|x64.ActiveCfg = Release|x64 {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|x64.Build.0 = Release|x64 + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Bounds|x86.Build.0 = Bounds|Any CPU {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|x64.ActiveCfg = Debug|x64 {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|x64.Build.0 = Debug|x64 + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|x86.ActiveCfg = Debug|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Debug|x86.Build.0 = Debug|Any CPU {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|x64.ActiveCfg = Release|x64 {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|x64.Build.0 = Release|x64 + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|Any CPU.Build.0 = Release|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|x86.ActiveCfg = Release|Any CPU + {8ED64FCC-6F3E-55FF-AA04-B0F2A67A3044}.Release|x86.Build.0 = Release|Any CPU {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|x64.ActiveCfg = Release|x64 {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|x64.Build.0 = Release|x64 + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Bounds|x86.Build.0 = Bounds|Any CPU {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|x64.ActiveCfg = Debug|x64 {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|x64.Build.0 = Debug|x64 + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|Any CPU.Build.0 = Debug|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|x86.ActiveCfg = Debug|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Debug|x86.Build.0 = Debug|Any CPU {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|x64.ActiveCfg = Release|x64 {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|x64.Build.0 = Release|x64 + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|Any CPU.ActiveCfg = Release|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|Any CPU.Build.0 = Release|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|x86.ActiveCfg = Release|Any CPU + {65C872FA-2DC7-5EC2-9A19-EDB4FA325934}.Release|x86.Build.0 = Release|Any CPU {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|x64.ActiveCfg = Release|x64 {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|x64.Build.0 = Release|x64 + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Bounds|x86.Build.0 = Bounds|Any CPU {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|x64.ActiveCfg = Debug|x64 {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|x64.Build.0 = Debug|x64 + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|x86.ActiveCfg = Debug|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Debug|x86.Build.0 = Debug|Any CPU {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|x64.ActiveCfg = Release|x64 {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|x64.Build.0 = Release|x64 + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|Any CPU.Build.0 = Release|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|x86.ActiveCfg = Release|Any CPU + {BD5AFBAD-6C0C-5C44-912D-D26745CF8F62}.Release|x86.Build.0 = Release|Any CPU {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|x64.ActiveCfg = Release|x64 {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|x64.Build.0 = Release|x64 + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Bounds|x86.Build.0 = Bounds|Any CPU {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|x64.ActiveCfg = Debug|x64 {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|x64.Build.0 = Debug|x64 + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Debug|x86.Build.0 = Debug|Any CPU {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|x64.ActiveCfg = Release|x64 {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|x64.Build.0 = Release|x64 + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|Any CPU.Build.0 = Release|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|x86.ActiveCfg = Release|Any CPU + {C5AA04DD-F91B-5156-BD40-4A761058AC64}.Release|x86.Build.0 = Release|Any CPU {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|x64.ActiveCfg = Release|x64 {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|x64.Build.0 = Release|x64 + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Bounds|x86.Build.0 = Bounds|Any CPU {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|x64.ActiveCfg = Debug|x64 {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|x64.Build.0 = Debug|x64 + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|x86.ActiveCfg = Debug|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Debug|x86.Build.0 = Debug|Any CPU {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|x64.ActiveCfg = Release|x64 {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|x64.Build.0 = Release|x64 + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|Any CPU.Build.0 = Release|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|x86.ActiveCfg = Release|Any CPU + {F2525F78-38CD-5E36-A854-E16BE8A1B8FF}.Release|x86.Build.0 = Release|Any CPU {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|x64.ActiveCfg = Release|x64 {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|x64.Build.0 = Release|x64 + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Bounds|x86.Build.0 = Bounds|Any CPU {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|x64.ActiveCfg = Debug|x64 {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|x64.Build.0 = Debug|x64 + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|x86.ActiveCfg = Debug|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Debug|x86.Build.0 = Debug|Any CPU {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|x64.ActiveCfg = Release|x64 {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|x64.Build.0 = Release|x64 + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|Any CPU.Build.0 = Release|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|x86.ActiveCfg = Release|Any CPU + {170E9760-4036-5CC4-951D-DAFDBCEF7BEA}.Release|x86.Build.0 = Release|Any CPU {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|x64.ActiveCfg = Release|x64 {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|x64.Build.0 = Release|x64 + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Bounds|x86.Build.0 = Bounds|Any CPU {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|x64.ActiveCfg = Debug|x64 {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|x64.Build.0 = Debug|x64 + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Debug|x86.Build.0 = Debug|Any CPU {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|x64.ActiveCfg = Release|x64 {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|x64.Build.0 = Release|x64 + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|Any CPU.Build.0 = Release|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|x86.ActiveCfg = Release|Any CPU + {DDDCFA1C-DC3E-54B7-9B3A-497B4FBE1510}.Release|x86.Build.0 = Release|Any CPU {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|x64.ActiveCfg = Release|x64 {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|x64.Build.0 = Release|x64 + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Bounds|x86.Build.0 = Bounds|Any CPU {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|x64.ActiveCfg = Debug|x64 {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|x64.Build.0 = Debug|x64 + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|x86.ActiveCfg = Debug|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Debug|x86.Build.0 = Debug|Any CPU {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|x64.ActiveCfg = Release|x64 {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|x64.Build.0 = Release|x64 + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|Any CPU.Build.0 = Release|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|x86.ActiveCfg = Release|Any CPU + {83DC33D4-9323-56B1-865A-56CD516EE52A}.Release|x86.Build.0 = Release|Any CPU {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|x64.ActiveCfg = Release|x64 {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|x64.Build.0 = Release|x64 + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Bounds|x86.Build.0 = Bounds|Any CPU {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|x64.ActiveCfg = Debug|x64 {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|x64.Build.0 = Debug|x64 + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|x86.ActiveCfg = Debug|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Debug|x86.Build.0 = Debug|Any CPU {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|x64.ActiveCfg = Release|x64 {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|x64.Build.0 = Release|x64 + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|Any CPU.Build.0 = Release|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|x86.ActiveCfg = Release|Any CPU + {DD84503B-AB8B-5FFD-B15F-8DE447F7BCDD}.Release|x86.Build.0 = Release|Any CPU {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|x64.ActiveCfg = Release|x64 {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|x64.Build.0 = Release|x64 + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Bounds|x86.Build.0 = Bounds|Any CPU {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|x64.ActiveCfg = Debug|x64 {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|x64.Build.0 = Debug|x64 + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Debug|x86.Build.0 = Debug|Any CPU {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|x64.ActiveCfg = Release|x64 {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|x64.Build.0 = Release|x64 + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|Any CPU.Build.0 = Release|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|x86.ActiveCfg = Release|Any CPU + {1B8FE336-2272-5424-A36A-7C786F9FE388}.Release|x86.Build.0 = Release|Any CPU {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|x64.ActiveCfg = Release|x64 {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|x64.Build.0 = Release|x64 + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Bounds|x86.Build.0 = Bounds|Any CPU {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|x64.ActiveCfg = Debug|x64 {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|x64.Build.0 = Debug|x64 + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Debug|x86.Build.0 = Debug|Any CPU {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|x64.ActiveCfg = Release|x64 {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|x64.Build.0 = Release|x64 + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|Any CPU.Build.0 = Release|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|x86.ActiveCfg = Release|Any CPU + {BF01268F-E755-5577-B8D7-9014D7591A2A}.Release|x86.Build.0 = Release|Any CPU {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|x64.ActiveCfg = Release|x64 {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|x64.Build.0 = Release|x64 + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Bounds|x86.Build.0 = Bounds|Any CPU {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|x64.ActiveCfg = Debug|x64 {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|x64.Build.0 = Debug|x64 + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|x86.ActiveCfg = Debug|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Debug|x86.Build.0 = Debug|Any CPU {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|x64.ActiveCfg = Release|x64 {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|x64.Build.0 = Release|x64 + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|Any CPU.Build.0 = Release|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|x86.ActiveCfg = Release|Any CPU + {4B95DD96-AB0A-571E-81E8-3035ECCC8D47}.Release|x86.Build.0 = Release|Any CPU {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|x64.ActiveCfg = Release|x64 {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|x64.Build.0 = Release|x64 + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Bounds|x86.Build.0 = Bounds|Any CPU {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|x64.ActiveCfg = Debug|x64 {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|x64.Build.0 = Debug|x64 + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|x86.ActiveCfg = Debug|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Debug|x86.Build.0 = Debug|Any CPU {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|x64.ActiveCfg = Release|x64 {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|x64.Build.0 = Release|x64 + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|Any CPU.Build.0 = Release|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|x86.ActiveCfg = Release|Any CPU + {21F54BD0-152A-547C-A940-2BCFEA8D1730}.Release|x86.Build.0 = Release|Any CPU {66361165-1489-5B17-8969-4A6253C00931}.Bounds|x64.ActiveCfg = Release|x64 {66361165-1489-5B17-8969-4A6253C00931}.Bounds|x64.Build.0 = Release|x64 + {66361165-1489-5B17-8969-4A6253C00931}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Bounds|x86.Build.0 = Bounds|Any CPU {66361165-1489-5B17-8969-4A6253C00931}.Debug|x64.ActiveCfg = Debug|x64 {66361165-1489-5B17-8969-4A6253C00931}.Debug|x64.Build.0 = Debug|x64 + {66361165-1489-5B17-8969-4A6253C00931}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Debug|Any CPU.Build.0 = Debug|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Debug|x86.ActiveCfg = Debug|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Debug|x86.Build.0 = Debug|Any CPU {66361165-1489-5B17-8969-4A6253C00931}.Release|x64.ActiveCfg = Release|x64 {66361165-1489-5B17-8969-4A6253C00931}.Release|x64.Build.0 = Release|x64 + {66361165-1489-5B17-8969-4A6253C00931}.Release|Any CPU.ActiveCfg = Release|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Release|Any CPU.Build.0 = Release|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Release|x86.ActiveCfg = Release|Any CPU + {66361165-1489-5B17-8969-4A6253C00931}.Release|x86.Build.0 = Release|Any CPU {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|x64.ActiveCfg = Release|x64 {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|x64.Build.0 = Release|x64 + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Bounds|x86.Build.0 = Bounds|Any CPU {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|x64.ActiveCfg = Debug|x64 {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|x64.Build.0 = Debug|x64 + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Debug|x86.Build.0 = Debug|Any CPU {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|x64.ActiveCfg = Release|x64 {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|x64.Build.0 = Release|x64 + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|Any CPU.Build.0 = Release|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|x86.ActiveCfg = Release|Any CPU + {1DD0C70B-EA9D-593E-BF23-72FEAB6849DF}.Release|x86.Build.0 = Release|Any CPU {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|x64.ActiveCfg = Release|x64 {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|x64.Build.0 = Release|x64 + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Bounds|x86.Build.0 = Bounds|Any CPU {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|x64.ActiveCfg = Debug|x64 {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|x64.Build.0 = Debug|x64 + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Debug|x86.Build.0 = Debug|Any CPU {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|x64.ActiveCfg = Release|x64 {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|x64.Build.0 = Release|x64 + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|x86.ActiveCfg = Release|Any CPU + {E5F82767-7DC7-599F-BC29-AAFE4AC98060}.Release|x86.Build.0 = Release|Any CPU {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|x64.ActiveCfg = Release|x64 {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|x64.Build.0 = Release|x64 + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Bounds|x86.Build.0 = Bounds|Any CPU {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|x64.ActiveCfg = Debug|x64 {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|x64.Build.0 = Debug|x64 + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|x86.ActiveCfg = Debug|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Debug|x86.Build.0 = Debug|Any CPU {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|x64.ActiveCfg = Release|x64 {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|x64.Build.0 = Release|x64 + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|Any CPU.Build.0 = Release|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|x86.ActiveCfg = Release|Any CPU + {09D7C8FE-DD9B-5C1C-9A4D-9D61B26E878E}.Release|x86.Build.0 = Release|Any CPU {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|x64.ActiveCfg = Release|x64 {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|x64.Build.0 = Release|x64 + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Bounds|x86.Build.0 = Bounds|Any CPU {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|x64.ActiveCfg = Debug|x64 {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|x64.Build.0 = Debug|x64 + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|x86.ActiveCfg = Debug|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Debug|x86.Build.0 = Debug|Any CPU {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|x64.ActiveCfg = Release|x64 {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|x64.Build.0 = Release|x64 + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|Any CPU.Build.0 = Release|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|x86.ActiveCfg = Release|Any CPU + {2310A14E-5FFA-5939-885C-DA681EAFC168}.Release|x86.Build.0 = Release|Any CPU {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|x64.ActiveCfg = Release|x64 {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|x64.Build.0 = Release|x64 + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Bounds|x86.Build.0 = Bounds|Any CPU {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|x64.ActiveCfg = Debug|x64 {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|x64.Build.0 = Debug|x64 + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|x86.ActiveCfg = Debug|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Debug|x86.Build.0 = Debug|Any CPU {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|x64.ActiveCfg = Release|x64 {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|x64.Build.0 = Release|x64 + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|Any CPU.Build.0 = Release|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|x86.ActiveCfg = Release|Any CPU + {3E1BAF09-02C0-55BF-8683-3FAACFE6F137}.Release|x86.Build.0 = Release|Any CPU {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|x64.ActiveCfg = Release|x64 {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|x64.Build.0 = Release|x64 + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Bounds|x86.Build.0 = Bounds|Any CPU {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|x64.ActiveCfg = Debug|x64 {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|x64.Build.0 = Debug|x64 + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Debug|x86.Build.0 = Debug|Any CPU {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|x64.ActiveCfg = Release|x64 {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|x64.Build.0 = Release|x64 + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|Any CPU.Build.0 = Release|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|x86.ActiveCfg = Release|Any CPU + {8A29FFD3-0F85-58FE-94C1-ECA085D4C29A}.Release|x86.Build.0 = Release|Any CPU {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|x64.ActiveCfg = Release|x64 {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|x64.Build.0 = Release|x64 + {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Bounds|x86.Build.0 = Bounds|Any CPU {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|x64.ActiveCfg = Debug|x64 {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|x64.Build.0 = Debug|x64 + {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|x86.ActiveCfg = Debug|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Debug|x86.Build.0 = Debug|Any CPU {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|x64.ActiveCfg = Release|x64 {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|x64.Build.0 = Release|x64 + {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|Any CPU.Build.0 = Release|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|x86.ActiveCfg = Release|Any CPU + {94AD32DE-8AA2-547E-90F9-99169687406F}.Release|x86.Build.0 = Release|Any CPU {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|x64.ActiveCfg = Release|x64 {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|x64.Build.0 = Release|x64 + {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Bounds|x86.Build.0 = Bounds|Any CPU {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|x64.ActiveCfg = Debug|x64 {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|x64.Build.0 = Debug|x64 + {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|x86.ActiveCfg = Debug|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Debug|x86.Build.0 = Debug|Any CPU {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|x64.ActiveCfg = Release|x64 {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|x64.Build.0 = Release|x64 + {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|Any CPU.Build.0 = Release|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|x86.ActiveCfg = Release|Any CPU + {EC934204-1D3A-5575-A500-CB7923C440E2}.Release|x86.Build.0 = Release|Any CPU {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|x64.ActiveCfg = Release|x64 {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|x64.Build.0 = Release|x64 + {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Bounds|x86.Build.0 = Bounds|Any CPU {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|x64.ActiveCfg = Debug|x64 {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|x64.Build.0 = Debug|x64 + {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|x86.ActiveCfg = Debug|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Debug|x86.Build.0 = Debug|Any CPU {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|x64.ActiveCfg = Release|x64 {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|x64.Build.0 = Release|x64 + {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|Any CPU.Build.0 = Release|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|x86.ActiveCfg = Release|Any CPU + {8336DC7C-954B-5076-9315-D7DC5317282B}.Release|x86.Build.0 = Release|Any CPU {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|x64.ActiveCfg = Release|x64 {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|x64.Build.0 = Release|x64 + {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Bounds|x86.Build.0 = Bounds|Any CPU {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|x64.ActiveCfg = Debug|x64 {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|x64.Build.0 = Debug|x64 + {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|x86.ActiveCfg = Debug|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Debug|x86.Build.0 = Debug|Any CPU {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|x64.ActiveCfg = Release|x64 {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|x64.Build.0 = Release|x64 + {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|Any CPU.Build.0 = Release|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|x86.ActiveCfg = Release|Any CPU + {04546E35-9A3A-5629-8282-3683A5D848F9}.Release|x86.Build.0 = Release|Any CPU {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|x64.ActiveCfg = Release|x64 {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|x64.Build.0 = Release|x64 + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Bounds|x86.Build.0 = Bounds|Any CPU {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|x64.ActiveCfg = Debug|x64 {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|x64.Build.0 = Debug|x64 + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|x86.ActiveCfg = Debug|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Debug|x86.Build.0 = Debug|Any CPU {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|x64.ActiveCfg = Release|x64 {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|x64.Build.0 = Release|x64 + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|Any CPU.Build.0 = Release|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|x86.ActiveCfg = Release|Any CPU + {7C859385-3602-59D1-9A7E-E81E7C6EBBE4}.Release|x86.Build.0 = Release|Any CPU {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|x64.ActiveCfg = Release|x64 {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|x64.Build.0 = Release|x64 + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Bounds|x86.Build.0 = Bounds|Any CPU {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|x64.ActiveCfg = Debug|x64 {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|x64.Build.0 = Debug|x64 + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|x86.ActiveCfg = Debug|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Debug|x86.Build.0 = Debug|Any CPU {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|x64.ActiveCfg = Release|x64 {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|x64.Build.0 = Release|x64 + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|Any CPU.Build.0 = Release|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|x86.ActiveCfg = Release|Any CPU + {46A84616-92E0-567E-846E-DF0C203CF0D2}.Release|x86.Build.0 = Release|Any CPU {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|x64.ActiveCfg = Release|x64 {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|x64.Build.0 = Release|x64 + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Bounds|x86.Build.0 = Bounds|Any CPU {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|x64.ActiveCfg = Debug|x64 {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|x64.Build.0 = Debug|x64 + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|x86.ActiveCfg = Debug|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Debug|x86.Build.0 = Debug|Any CPU {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|x64.ActiveCfg = Release|x64 {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|x64.Build.0 = Release|x64 + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|Any CPU.Build.0 = Release|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|x86.ActiveCfg = Release|Any CPU + {910ED78F-AE00-5547-ADEC-A0E54BF98B8D}.Release|x86.Build.0 = Release|Any CPU {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|x64.ActiveCfg = Release|x64 {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|x64.Build.0 = Release|x64 + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Bounds|x86.Build.0 = Bounds|Any CPU {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|x64.ActiveCfg = Debug|x64 {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|x64.Build.0 = Debug|x64 + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|x86.ActiveCfg = Debug|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Debug|x86.Build.0 = Debug|Any CPU {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|x64.ActiveCfg = Release|x64 {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|x64.Build.0 = Release|x64 + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|Any CPU.Build.0 = Release|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|x86.ActiveCfg = Release|Any CPU + {68C6DB83-7D0F-5F31-9307-6489E21F74E5}.Release|x86.Build.0 = Release|Any CPU {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|x64.ActiveCfg = Release|x64 {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|x64.Build.0 = Release|x64 + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Bounds|x86.Build.0 = Bounds|Any CPU {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|x64.ActiveCfg = Debug|x64 {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|x64.Build.0 = Debug|x64 + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|x86.ActiveCfg = Debug|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Debug|x86.Build.0 = Debug|Any CPU {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|x64.ActiveCfg = Release|x64 {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|x64.Build.0 = Release|x64 + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|Any CPU.Build.0 = Release|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|x86.ActiveCfg = Release|Any CPU + {E63B6F76-5CD3-5757-93D7-E050CB412F23}.Release|x86.Build.0 = Release|Any CPU {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|x64.ActiveCfg = Release|x64 {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|x64.Build.0 = Release|x64 + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Bounds|x86.Build.0 = Bounds|Any CPU {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|x64.ActiveCfg = Debug|x64 {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|x64.Build.0 = Debug|x64 + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|x86.ActiveCfg = Debug|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Debug|x86.Build.0 = Debug|Any CPU {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|x64.ActiveCfg = Release|x64 {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|x64.Build.0 = Release|x64 + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|Any CPU.Build.0 = Release|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|x86.ActiveCfg = Release|Any CPU + {712CF492-5D74-5464-93CA-EAB5BE54D09B}.Release|x86.Build.0 = Release|Any CPU {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|x64.ActiveCfg = Release|x64 {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|x64.Build.0 = Release|x64 + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Bounds|x86.Build.0 = Bounds|Any CPU {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|x64.ActiveCfg = Debug|x64 {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|x64.Build.0 = Debug|x64 + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|x86.ActiveCfg = Debug|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Debug|x86.Build.0 = Debug|Any CPU {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|x64.ActiveCfg = Release|x64 {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|x64.Build.0 = Release|x64 + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|Any CPU.Build.0 = Release|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|x86.ActiveCfg = Release|Any CPU + {D2BAD63B-0914-5014-BCE8-8D767A871F06}.Release|x86.Build.0 = Release|Any CPU {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|x64.ActiveCfg = Release|x64 {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|x64.Build.0 = Release|x64 + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Bounds|x86.Build.0 = Bounds|Any CPU {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|x64.ActiveCfg = Debug|x64 {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|x64.Build.0 = Debug|x64 + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|x86.ActiveCfg = Debug|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Debug|x86.Build.0 = Debug|Any CPU {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|x64.ActiveCfg = Release|x64 {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|x64.Build.0 = Release|x64 + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|Any CPU.Build.0 = Release|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|x86.ActiveCfg = Release|Any CPU + {98E5183C-F4A6-5DAA-AFB8-B63F75ACA860}.Release|x86.Build.0 = Release|Any CPU {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|x64.ActiveCfg = Release|x64 {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|x64.Build.0 = Release|x64 + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Bounds|x86.Build.0 = Bounds|Any CPU {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|x64.ActiveCfg = Debug|x64 {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|x64.Build.0 = Debug|x64 + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|x86.ActiveCfg = Debug|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Debug|x86.Build.0 = Debug|Any CPU {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|x64.ActiveCfg = Release|x64 {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|x64.Build.0 = Release|x64 + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|Any CPU.Build.0 = Release|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|x86.ActiveCfg = Release|Any CPU + {FDC1EE9E-73F7-5EF2-9868-E44ACB00F168}.Release|x86.Build.0 = Release|Any CPU {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|x64.ActiveCfg = Release|x64 {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|x64.Build.0 = Release|x64 + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Bounds|x86.Build.0 = Bounds|Any CPU {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|x64.ActiveCfg = Debug|x64 {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|x64.Build.0 = Debug|x64 + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|Any CPU.Build.0 = Debug|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|x86.ActiveCfg = Debug|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Debug|x86.Build.0 = Debug|Any CPU {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|x64.ActiveCfg = Release|x64 {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|x64.Build.0 = Release|x64 + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|Any CPU.ActiveCfg = Release|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|Any CPU.Build.0 = Release|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|x86.ActiveCfg = Release|Any CPU + {515DEC49-6C0F-5F02-AC05-69AC6AF51639}.Release|x86.Build.0 = Release|Any CPU {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|x64.ActiveCfg = Release|x64 {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|x64.Build.0 = Release|x64 + {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Bounds|x86.Build.0 = Bounds|Any CPU {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|x64.ActiveCfg = Debug|x64 {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|x64.Build.0 = Debug|x64 + {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|x86.ActiveCfg = Debug|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Debug|x86.Build.0 = Debug|Any CPU {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|x64.ActiveCfg = Release|x64 {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|x64.Build.0 = Release|x64 + {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|Any CPU.Build.0 = Release|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|x86.ActiveCfg = Release|Any CPU + {70163155-93C1-5816-A1D4-1EEA0215298C}.Release|x86.Build.0 = Release|Any CPU {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|x64.ActiveCfg = Release|x64 {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|x64.Build.0 = Release|x64 + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Bounds|x86.Build.0 = Bounds|Any CPU {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|x64.ActiveCfg = Debug|x64 {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|x64.Build.0 = Debug|x64 + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Debug|x86.Build.0 = Debug|Any CPU {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|x64.ActiveCfg = Release|x64 {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|x64.Build.0 = Release|x64 + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|Any CPU.Build.0 = Release|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|x86.ActiveCfg = Release|Any CPU + {EFB41F48-1BF6-549C-8D93-59F99B3EA5D5}.Release|x86.Build.0 = Release|Any CPU {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|x64.ActiveCfg = Release|x64 {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|x64.Build.0 = Release|x64 + {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Bounds|x86.Build.0 = Bounds|Any CPU {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|x64.ActiveCfg = Debug|x64 {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|x64.Build.0 = Debug|x64 + {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|x86.ActiveCfg = Debug|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Debug|x86.Build.0 = Debug|Any CPU {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|x64.ActiveCfg = Release|x64 {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|x64.Build.0 = Release|x64 + {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|Any CPU.Build.0 = Release|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|x86.ActiveCfg = Release|Any CPU + {AB011392-76C6-5D67-9623-CA9B2680B899}.Release|x86.Build.0 = Release|Any CPU {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|x64.ActiveCfg = Release|x64 {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|x64.Build.0 = Release|x64 + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Bounds|x86.Build.0 = Bounds|Any CPU {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|x64.ActiveCfg = Debug|x64 {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|x64.Build.0 = Debug|x64 + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|x86.ActiveCfg = Debug|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Debug|x86.Build.0 = Debug|Any CPU {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|x64.ActiveCfg = Release|x64 {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|x64.Build.0 = Release|x64 + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|Any CPU.Build.0 = Release|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|x86.ActiveCfg = Release|Any CPU + {3072F4ED-E1F0-5C16-8CCA-CE3AE6D8760A}.Release|x86.Build.0 = Release|Any CPU {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|x64.ActiveCfg = Release|x64 {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|x64.Build.0 = Release|x64 + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Bounds|x86.Build.0 = Bounds|Any CPU {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|x64.ActiveCfg = Debug|x64 {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|x64.Build.0 = Debug|x64 + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|x86.ActiveCfg = Debug|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Debug|x86.Build.0 = Debug|Any CPU {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|x64.ActiveCfg = Release|x64 {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|x64.Build.0 = Release|x64 + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|Any CPU.Build.0 = Release|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|x86.ActiveCfg = Release|Any CPU + {17AE7011-A346-5BAE-A021-552E7A3A86DD}.Release|x86.Build.0 = Release|Any CPU {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|x64.ActiveCfg = Release|x64 {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|x64.Build.0 = Release|x64 + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Bounds|x86.Build.0 = Bounds|Any CPU {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|x64.ActiveCfg = Debug|x64 {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|x64.Build.0 = Debug|x64 + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Debug|x86.Build.0 = Debug|Any CPU {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|x64.ActiveCfg = Release|x64 {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|x64.Build.0 = Release|x64 + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|Any CPU.Build.0 = Release|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|x86.ActiveCfg = Release|Any CPU + {6AD8FA57-72AB-5C43-A2C6-02D5D26AC432}.Release|x86.Build.0 = Release|Any CPU {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|x64.ActiveCfg = Release|x64 {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|x64.Build.0 = Release|x64 + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Bounds|x86.Build.0 = Bounds|Any CPU {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|x64.ActiveCfg = Debug|x64 {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|x64.Build.0 = Debug|x64 + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Debug|x86.Build.0 = Debug|Any CPU {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|x64.ActiveCfg = Release|x64 {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|x64.Build.0 = Release|x64 + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|Any CPU.Build.0 = Release|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|x86.ActiveCfg = Release|Any CPU + {5F9BBC7F-3CE1-5F77-956F-B7650E1FE52E}.Release|x86.Build.0 = Release|Any CPU {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|x64.ActiveCfg = Release|x64 {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|x64.Build.0 = Release|x64 + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Bounds|x86.Build.0 = Bounds|Any CPU {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|x64.ActiveCfg = Debug|x64 {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|x64.Build.0 = Debug|x64 + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|x86.ActiveCfg = Debug|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Debug|x86.Build.0 = Debug|Any CPU {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|x64.ActiveCfg = Release|x64 {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|x64.Build.0 = Release|x64 + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|Any CPU.Build.0 = Release|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|x86.ActiveCfg = Release|Any CPU + {9A7E3C5B-2D1F-4E8A-9B3C-F6D0E1A2B4C8}.Release|x86.Build.0 = Release|Any CPU {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|x64.ActiveCfg = Release|x64 {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|x64.Build.0 = Release|x64 + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Bounds|x86.Build.0 = Bounds|Any CPU {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|x64.ActiveCfg = Debug|x64 {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|x64.Build.0 = Debug|x64 + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|x86.ActiveCfg = Debug|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Debug|x86.Build.0 = Debug|Any CPU {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|x64.ActiveCfg = Release|x64 {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|x64.Build.0 = Release|x64 + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|Any CPU.Build.0 = Release|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|x86.ActiveCfg = Release|Any CPU + {D4F47DD8-A0E7-5081-808A-5286F873DC13}.Release|x86.Build.0 = Release|Any CPU {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|x64.ActiveCfg = Release|x64 {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|x64.Build.0 = Release|x64 + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Bounds|x86.Build.0 = Bounds|Any CPU {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|x64.ActiveCfg = Debug|x64 {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|x64.Build.0 = Debug|x64 + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|x86.ActiveCfg = Debug|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Debug|x86.Build.0 = Debug|Any CPU {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|x64.ActiveCfg = Release|x64 {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|x64.Build.0 = Release|x64 + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|Any CPU.Build.0 = Release|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|x86.ActiveCfg = Release|Any CPU + {2EB628C9-EC23-5394-8BEB-B7542360FEAE}.Release|x86.Build.0 = Release|Any CPU {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|x64.ActiveCfg = Release|x64 {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|x64.Build.0 = Release|x64 + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Bounds|x86.Build.0 = Bounds|Any CPU {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|x64.ActiveCfg = Debug|x64 {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|x64.Build.0 = Debug|x64 + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|x86.ActiveCfg = Debug|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Debug|x86.Build.0 = Debug|Any CPU {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|x64.ActiveCfg = Release|x64 {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|x64.Build.0 = Release|x64 + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|Any CPU.Build.0 = Release|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|x86.ActiveCfg = Release|Any CPU + {B9B1AF40-53E1-54A3-B2F1-85EFE95F5A89}.Release|x86.Build.0 = Release|Any CPU {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|x64.ActiveCfg = Release|x64 {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|x64.Build.0 = Release|x64 + {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Bounds|x86.Build.0 = Bounds|Any CPU {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|x64.ActiveCfg = Debug|x64 {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|x64.Build.0 = Debug|x64 + {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|x86.ActiveCfg = Debug|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Debug|x86.Build.0 = Debug|Any CPU {DA1CAEE2-340C-51E7-980B-916545074600}.Release|x64.ActiveCfg = Release|x64 {DA1CAEE2-340C-51E7-980B-916545074600}.Release|x64.Build.0 = Release|x64 + {DA1CAEE2-340C-51E7-980B-916545074600}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Release|Any CPU.Build.0 = Release|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Release|x86.ActiveCfg = Release|Any CPU + {DA1CAEE2-340C-51E7-980B-916545074600}.Release|x86.Build.0 = Release|Any CPU {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|x64.ActiveCfg = Release|x64 {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|x64.Build.0 = Release|x64 + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Bounds|x86.Build.0 = Bounds|Any CPU {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|x64.ActiveCfg = Debug|x64 {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|x64.Build.0 = Debug|x64 + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|x86.ActiveCfg = Debug|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Debug|x86.Build.0 = Debug|Any CPU {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|x64.ActiveCfg = Release|x64 {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|x64.Build.0 = Release|x64 + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|Any CPU.Build.0 = Release|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|x86.ActiveCfg = Release|Any CPU + {B2E94D3C-45D7-5BE8-AEA0-0E9234FCF50D}.Release|x86.Build.0 = Release|Any CPU {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|x64.ActiveCfg = Release|x64 {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|x64.Build.0 = Release|x64 + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Bounds|x86.Build.0 = Bounds|Any CPU {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|x64.ActiveCfg = Debug|x64 {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|x64.Build.0 = Debug|x64 + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|x86.ActiveCfg = Debug|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Debug|x86.Build.0 = Debug|Any CPU {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|x64.ActiveCfg = Release|x64 {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|x64.Build.0 = Release|x64 + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|Any CPU.Build.0 = Release|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|x86.ActiveCfg = Release|Any CPU + {1C758320-DE0A-50F3-8892-B0F7397CFA61}.Release|x86.Build.0 = Release|Any CPU {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|x64.ActiveCfg = Release|x64 {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|x64.Build.0 = Release|x64 + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Bounds|x86.Build.0 = Bounds|Any CPU {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|x64.ActiveCfg = Debug|x64 {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|x64.Build.0 = Debug|x64 + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Debug|x86.Build.0 = Debug|Any CPU {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|x64.ActiveCfg = Release|x64 {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|x64.Build.0 = Release|x64 + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|Any CPU.Build.0 = Release|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|x86.ActiveCfg = Release|Any CPU + {9B1C17E4-3086-53B9-B1DC-8A39117E237F}.Release|x86.Build.0 = Release|Any CPU {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|x64.ActiveCfg = Release|x64 {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|x64.Build.0 = Release|x64 + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Bounds|x86.Build.0 = Bounds|Any CPU {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|x64.ActiveCfg = Debug|x64 {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|x64.Build.0 = Debug|x64 + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|x86.ActiveCfg = Debug|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Debug|x86.Build.0 = Debug|Any CPU {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|x64.ActiveCfg = Release|x64 {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|x64.Build.0 = Release|x64 + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|Any CPU.Build.0 = Release|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|x86.ActiveCfg = Release|Any CPU + {2861A99F-3390-52B4-A2D8-0F80A62DB108}.Release|x86.Build.0 = Release|Any CPU {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|x64.ActiveCfg = Release|x64 {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|x64.Build.0 = Release|x64 + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Bounds|x86.Build.0 = Bounds|Any CPU {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|x64.ActiveCfg = Debug|x64 {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|x64.Build.0 = Debug|x64 + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Debug|x86.Build.0 = Debug|Any CPU {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|x64.ActiveCfg = Release|x64 {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|x64.Build.0 = Release|x64 + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|Any CPU.Build.0 = Release|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|x86.ActiveCfg = Release|Any CPU + {5B1DFFF7-6A59-5955-B77D-42DBF12721D1}.Release|x86.Build.0 = Release|Any CPU {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|x64.ActiveCfg = Release|x64 {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|x64.Build.0 = Release|x64 + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Bounds|x86.Build.0 = Bounds|Any CPU {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|x64.ActiveCfg = Debug|x64 {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|x64.Build.0 = Debug|x64 + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|x86.ActiveCfg = Debug|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Debug|x86.Build.0 = Debug|Any CPU {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|x64.ActiveCfg = Release|x64 {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|x64.Build.0 = Release|x64 + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|Any CPU.Build.0 = Release|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|x86.ActiveCfg = Release|Any CPU + {1308E147-8B51-55E0-B475-10A0053F9AAF}.Release|x86.Build.0 = Release|Any CPU {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|x64.ActiveCfg = Bounds|x64 {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|x64.Build.0 = Bounds|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|Any CPU.ActiveCfg = Bounds|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|Any CPU.Build.0 = Bounds|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|x86.ActiveCfg = Bounds|Win32 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Bounds|x86.Build.0 = Bounds|Win32 {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|x64.ActiveCfg = Debug|x64 {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|x64.Build.0 = Debug|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|Any CPU.ActiveCfg = Debug|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|Any CPU.Build.0 = Debug|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|x86.ActiveCfg = Debug|Win32 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Debug|x86.Build.0 = Debug|Win32 {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|x64.ActiveCfg = Release|x64 {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|x64.Build.0 = Release|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|Any CPU.ActiveCfg = Release|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|Any CPU.Build.0 = Release|x64 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|x86.ActiveCfg = Release|Win32 + {7F6B25EE-CD22-4E4C-898D-A0F846E6E9D4}.Release|x86.Build.0 = Release|Win32 {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|x64.ActiveCfg = Debug|x64 {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|x64.Build.0 = Debug|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|Any CPU.ActiveCfg = Bounds|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|Any CPU.Build.0 = Bounds|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|x86.ActiveCfg = Bounds|Win32 + {6396B488-4D34-48B2-8639-EEB90707405B}.Bounds|x86.Build.0 = Bounds|Win32 {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|x64.ActiveCfg = Debug|x64 {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|x64.Build.0 = Debug|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|Any CPU.ActiveCfg = Debug|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|Any CPU.Build.0 = Debug|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|x86.ActiveCfg = Debug|Win32 + {6396B488-4D34-48B2-8639-EEB90707405B}.Debug|x86.Build.0 = Debug|Win32 {6396B488-4D34-48B2-8639-EEB90707405B}.Release|x64.ActiveCfg = Release|x64 {6396B488-4D34-48B2-8639-EEB90707405B}.Release|x64.Build.0 = Release|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Release|Any CPU.ActiveCfg = Release|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Release|Any CPU.Build.0 = Release|x64 + {6396B488-4D34-48B2-8639-EEB90707405B}.Release|x86.ActiveCfg = Release|Win32 + {6396B488-4D34-48B2-8639-EEB90707405B}.Release|x86.Build.0 = Release|Win32 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|x64.ActiveCfg = Bounds|x64 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|x64.Build.0 = Bounds|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|Any CPU.ActiveCfg = Bounds|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|Any CPU.Build.0 = Bounds|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|x86.ActiveCfg = Bounds|Win32 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Bounds|x86.Build.0 = Bounds|Win32 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|x64.ActiveCfg = Debug|x64 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|x64.Build.0 = Debug|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|Any CPU.ActiveCfg = Debug|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|Any CPU.Build.0 = Debug|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|x86.ActiveCfg = Debug|Win32 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Debug|x86.Build.0 = Debug|Win32 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|x64.ActiveCfg = Release|x64 {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|x64.Build.0 = Release|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|Any CPU.ActiveCfg = Release|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|Any CPU.Build.0 = Release|x64 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|x86.ActiveCfg = Release|Win32 + {C86CA2EB-81B5-4411-B5B7-E983314E02DA}.Release|x86.Build.0 = Release|Win32 {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|x64.ActiveCfg = Release|x64 {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|x64.Build.0 = Release|x64 + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Bounds|x86.Build.0 = Bounds|Any CPU {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|x64.ActiveCfg = Debug|x64 {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|x64.Build.0 = Debug|x64 + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|Any CPU.Build.0 = Debug|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|x86.ActiveCfg = Debug|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Debug|x86.Build.0 = Debug|Any CPU {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|x64.ActiveCfg = Release|x64 {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|x64.Build.0 = Release|x64 + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|Any CPU.ActiveCfg = Release|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|Any CPU.Build.0 = Release|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|x86.ActiveCfg = Release|Any CPU + {34442A32-31DE-45A8-AD36-0ECFE4095523}.Release|x86.Build.0 = Release|Any CPU {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|x64.ActiveCfg = Debug|x64 {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|x64.Build.0 = Debug|x64 + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Bounds|x86.Build.0 = Bounds|Any CPU {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|x64.ActiveCfg = Debug|x64 {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|x64.Build.0 = Debug|x64 + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|x86.ActiveCfg = Debug|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Debug|x86.Build.0 = Debug|Any CPU {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|x64.ActiveCfg = Release|x64 {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|x64.Build.0 = Release|x64 + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|Any CPU.Build.0 = Release|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|x86.ActiveCfg = Release|Any CPU + {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5}.Release|x86.Build.0 = Release|Any CPU {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|x64.ActiveCfg = Debug|x64 {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|x64.Build.0 = Debug|x64 + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Bounds|x86.Build.0 = Bounds|Any CPU {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|x64.ActiveCfg = Debug|x64 {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|x64.Build.0 = Debug|x64 + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|x86.ActiveCfg = Debug|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Debug|x86.Build.0 = Debug|Any CPU {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|x64.ActiveCfg = Release|x64 {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|x64.Build.0 = Release|x64 + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|Any CPU.Build.0 = Release|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|x86.ActiveCfg = Release|Any CPU + {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE}.Release|x86.Build.0 = Release|Any CPU {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|x64.ActiveCfg = Debug|x64 {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|x64.Build.0 = Debug|x64 + {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Bounds|x86.Build.0 = Bounds|Any CPU {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|x64.ActiveCfg = Debug|x64 {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|x64.Build.0 = Debug|x64 + {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Debug|x86.Build.0 = Debug|Any CPU {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|x64.ActiveCfg = Release|x64 {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|x64.Build.0 = Release|x64 + {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|Any CPU.Build.0 = Release|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|x86.ActiveCfg = Release|Any CPU + {AF250D69-786B-40FA-A125-FD3F448CC283}.Release|x86.Build.0 = Release|Any CPU {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|x64.ActiveCfg = Debug|x64 {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|x64.Build.0 = Debug|x64 + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Bounds|x86.Build.0 = Bounds|Any CPU {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|x64.ActiveCfg = Debug|x64 {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|x64.Build.0 = Debug|x64 + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|x86.ActiveCfg = Debug|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Debug|x86.Build.0 = Debug|Any CPU {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|x64.ActiveCfg = Release|x64 {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|x64.Build.0 = Release|x64 + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|Any CPU.Build.0 = Release|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|x86.ActiveCfg = Release|Any CPU + {91D55536-1DE3-4279-9DD1-CA2CED068F42}.Release|x86.Build.0 = Release|Any CPU {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|x64.ActiveCfg = Debug|x64 {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|x64.Build.0 = Debug|x64 + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Bounds|x86.Build.0 = Bounds|Any CPU {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|x64.ActiveCfg = Debug|x64 {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|x64.Build.0 = Debug|x64 + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Debug|x86.Build.0 = Debug|Any CPU {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|x64.ActiveCfg = Release|x64 {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|x64.Build.0 = Release|x64 + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|Any CPU.Build.0 = Release|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|x86.ActiveCfg = Release|Any CPU + {BF5AD9CA-6FD6-49C7-B351-0630C11479C0}.Release|x86.Build.0 = Release|Any CPU {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|x64.ActiveCfg = Debug|x64 {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|x64.Build.0 = Debug|x64 + {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Bounds|x86.Build.0 = Bounds|Any CPU {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|x64.ActiveCfg = Debug|x64 {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|x64.Build.0 = Debug|x64 + {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|Any CPU.Build.0 = Debug|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|x86.ActiveCfg = Debug|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Debug|x86.Build.0 = Debug|Any CPU {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|x64.ActiveCfg = Release|x64 {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|x64.Build.0 = Release|x64 + {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|Any CPU.ActiveCfg = Release|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|Any CPU.Build.0 = Release|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|x86.ActiveCfg = Release|Any CPU + {EEE765C8-6812-4F9F-A100-42AA71921926}.Release|x86.Build.0 = Release|Any CPU {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|x64.ActiveCfg = Debug|x64 {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|x64.Build.0 = Debug|x64 + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Bounds|x86.Build.0 = Bounds|Any CPU {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|x64.ActiveCfg = Debug|x64 {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|x64.Build.0 = Debug|x64 + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|x86.ActiveCfg = Debug|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Debug|x86.Build.0 = Debug|Any CPU {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|x64.ActiveCfg = Release|x64 {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|x64.Build.0 = Release|x64 + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|Any CPU.Build.0 = Release|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|x86.ActiveCfg = Release|Any CPU + {8EF1E1AE-2226-4A9B-8942-CAB531956ED3}.Release|x86.Build.0 = Release|Any CPU {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|x64.ActiveCfg = Debug|x64 {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|x64.Build.0 = Debug|x64 + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Bounds|x86.Build.0 = Bounds|Any CPU {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|x64.ActiveCfg = Debug|x64 {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|x64.Build.0 = Debug|x64 + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Debug|x86.Build.0 = Debug|Any CPU {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|x64.ActiveCfg = Release|x64 {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|x64.Build.0 = Release|x64 + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|Any CPU.Build.0 = Release|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|x86.ActiveCfg = Release|Any CPU + {5DB1CEDA-B7AA-4594-9CE0-6D3A6F5763DF}.Release|x86.Build.0 = Release|Any CPU {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|x64.ActiveCfg = Debug|x64 {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|x64.Build.0 = Debug|x64 + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Bounds|x86.Build.0 = Bounds|Any CPU {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|x64.ActiveCfg = Debug|x64 {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|x64.Build.0 = Debug|x64 + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|Any CPU.Build.0 = Debug|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|x86.ActiveCfg = Debug|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Debug|x86.Build.0 = Debug|Any CPU {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|x64.ActiveCfg = Release|x64 {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|x64.Build.0 = Release|x64 + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|Any CPU.ActiveCfg = Release|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|Any CPU.Build.0 = Release|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|x86.ActiveCfg = Release|Any CPU + {996498A3-06F1-4B1B-B83F-15648DD8F514}.Release|x86.Build.0 = Release|Any CPU {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|x64.ActiveCfg = Debug|x64 {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|x64.Build.0 = Debug|x64 + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Bounds|x86.Build.0 = Bounds|Any CPU {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|x64.ActiveCfg = Debug|x64 {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|x64.Build.0 = Debug|x64 + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|x86.ActiveCfg = Debug|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Debug|x86.Build.0 = Debug|Any CPU {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|x64.ActiveCfg = Release|x64 {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|x64.Build.0 = Release|x64 + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|Any CPU.Build.0 = Release|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|x86.ActiveCfg = Release|Any CPU + {ECEC3C09-019A-4B31-B72A-C4A22DE88E84}.Release|x86.Build.0 = Release|Any CPU {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|x64.ActiveCfg = Debug|x64 {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|x64.Build.0 = Debug|x64 + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Bounds|x86.Build.0 = Bounds|Any CPU {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|x64.ActiveCfg = Debug|x64 {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|x64.Build.0 = Debug|x64 + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|Any CPU.Build.0 = Debug|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|x86.ActiveCfg = Debug|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Debug|x86.Build.0 = Debug|Any CPU {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|x64.ActiveCfg = Release|x64 {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|x64.Build.0 = Release|x64 + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|Any CPU.ActiveCfg = Release|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|Any CPU.Build.0 = Release|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|x86.ActiveCfg = Release|Any CPU + {98A04BE3-5CDA-4616-9396-E40B33CC9256}.Release|x86.Build.0 = Release|Any CPU {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|x64.ActiveCfg = Debug|x64 {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|x64.Build.0 = Debug|x64 + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Bounds|x86.Build.0 = Bounds|Any CPU {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|x64.ActiveCfg = Debug|x64 {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|x64.Build.0 = Debug|x64 + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Debug|x86.Build.0 = Debug|Any CPU {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|x64.ActiveCfg = Release|x64 {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|x64.Build.0 = Release|x64 + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|Any CPU.Build.0 = Release|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|x86.ActiveCfg = Release|Any CPU + {B4CDC940-DDA7-4EEC-87A6-8441CF47EE7E}.Release|x86.Build.0 = Release|Any CPU {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|x64.ActiveCfg = Debug|x64 {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|x64.Build.0 = Debug|x64 + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Bounds|x86.Build.0 = Bounds|Any CPU {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|x64.ActiveCfg = Debug|x64 {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|x64.Build.0 = Debug|x64 + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|x86.ActiveCfg = Debug|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Debug|x86.Build.0 = Debug|Any CPU {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|x64.ActiveCfg = Release|x64 {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|x64.Build.0 = Release|x64 + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|Any CPU.Build.0 = Release|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|x86.ActiveCfg = Release|Any CPU + {2C57CEB5-40DE-4229-89B8-BADE30687815}.Release|x86.Build.0 = Release|Any CPU {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|x64.ActiveCfg = Debug|x64 {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|x64.Build.0 = Debug|x64 + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Bounds|x86.Build.0 = Bounds|Any CPU {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|x64.ActiveCfg = Debug|x64 {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|x64.Build.0 = Debug|x64 + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|x86.ActiveCfg = Debug|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Debug|x86.Build.0 = Debug|Any CPU {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|x64.ActiveCfg = Release|x64 {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|x64.Build.0 = Release|x64 + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|Any CPU.Build.0 = Release|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|x86.ActiveCfg = Release|Any CPU + {0EDB239B-A523-4259-9123-EBA3B7E0139F}.Release|x86.Build.0 = Release|Any CPU {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|x64.ActiveCfg = Debug|x64 {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|x64.Build.0 = Debug|x64 + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Bounds|x86.Build.0 = Bounds|Any CPU {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|x64.ActiveCfg = Debug|x64 {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|x64.Build.0 = Debug|x64 + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|x86.ActiveCfg = Debug|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Debug|x86.Build.0 = Debug|Any CPU {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|x64.ActiveCfg = Release|x64 {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|x64.Build.0 = Release|x64 + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|Any CPU.Build.0 = Release|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|x86.ActiveCfg = Release|Any CPU + {AF29BF64-3F6E-4CD9-BC70-AFB68F563173}.Release|x86.Build.0 = Release|Any CPU {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|x64.ActiveCfg = Debug|x64 {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|x64.Build.0 = Debug|x64 + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Bounds|x86.Build.0 = Bounds|Any CPU {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|x64.ActiveCfg = Debug|x64 {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|x64.Build.0 = Debug|x64 + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|Any CPU.Build.0 = Debug|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|x86.ActiveCfg = Debug|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Debug|x86.Build.0 = Debug|Any CPU {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|x64.ActiveCfg = Release|x64 {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|x64.Build.0 = Release|x64 + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|Any CPU.ActiveCfg = Release|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|Any CPU.Build.0 = Release|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|x86.ActiveCfg = Release|Any CPU + {91219E68-FF1D-4DED-BB06-3A6AF46C0419}.Release|x86.Build.0 = Release|Any CPU {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|x64.ActiveCfg = Debug|x64 {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|x64.Build.0 = Debug|x64 + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Bounds|x86.Build.0 = Bounds|Any CPU {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|x64.ActiveCfg = Debug|x64 {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|x64.Build.0 = Debug|x64 + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Debug|x86.Build.0 = Debug|Any CPU {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|x64.ActiveCfg = Release|x64 {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|x64.Build.0 = Release|x64 + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|Any CPU.Build.0 = Release|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|x86.ActiveCfg = Release|Any CPU + {6A3359E7-E3DA-4CF6-B6F8-C6A4E8D9800B}.Release|x86.Build.0 = Release|Any CPU {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|x64.ActiveCfg = Debug|x64 {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|x64.Build.0 = Debug|x64 + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Bounds|x86.Build.0 = Bounds|Any CPU {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|x64.ActiveCfg = Debug|x64 {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|x64.Build.0 = Debug|x64 + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|x86.ActiveCfg = Debug|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Debug|x86.Build.0 = Debug|Any CPU {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|x64.ActiveCfg = Release|x64 {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|x64.Build.0 = Release|x64 + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|Any CPU.Build.0 = Release|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|x86.ActiveCfg = Release|Any CPU + {F9AC363B-9EDF-4A01-BBB8-79810AB208E4}.Release|x86.Build.0 = Release|Any CPU {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|x64.ActiveCfg = Debug|x64 {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|x64.Build.0 = Debug|x64 + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Bounds|x86.Build.0 = Bounds|Any CPU {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|x64.ActiveCfg = Debug|x64 {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|x64.Build.0 = Debug|x64 + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|x86.ActiveCfg = Debug|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Debug|x86.Build.0 = Debug|Any CPU {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|x64.ActiveCfg = Release|x64 {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|x64.Build.0 = Release|x64 + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|Any CPU.Build.0 = Release|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|x86.ActiveCfg = Release|Any CPU + {7B58967B-E363-40E9-ACC7-80F99FC1A50C}.Release|x86.Build.0 = Release|Any CPU {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|x64.ActiveCfg = Debug|x64 {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|x64.Build.0 = Debug|x64 + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Bounds|x86.Build.0 = Bounds|Any CPU {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|x64.ActiveCfg = Debug|x64 {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|x64.Build.0 = Debug|x64 + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Debug|x86.Build.0 = Debug|Any CPU {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|x64.ActiveCfg = Release|x64 {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|x64.Build.0 = Release|x64 + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|Any CPU.Build.0 = Release|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|x86.ActiveCfg = Release|Any CPU + {FB6117BB-990E-4493-A7FF-430E2CCE61BD}.Release|x86.Build.0 = Release|Any CPU {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|x64.ActiveCfg = Debug|x64 {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|x64.Build.0 = Debug|x64 + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Bounds|x86.Build.0 = Bounds|Any CPU {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|x64.ActiveCfg = Debug|x64 {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|x64.Build.0 = Debug|x64 + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|x86.ActiveCfg = Debug|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Debug|x86.Build.0 = Debug|Any CPU {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|x64.ActiveCfg = Release|x64 {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|x64.Build.0 = Release|x64 + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|Any CPU.Build.0 = Release|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|x86.ActiveCfg = Release|Any CPU + {77CBCCAA-F488-4533-BA39-C676BDDF292D}.Release|x86.Build.0 = Release|Any CPU {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|x64.ActiveCfg = Debug|x64 {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|x64.Build.0 = Debug|x64 + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Bounds|x86.Build.0 = Bounds|Any CPU {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|x64.ActiveCfg = Debug|x64 {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|x64.Build.0 = Debug|x64 + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Debug|x86.Build.0 = Debug|Any CPU {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|x64.ActiveCfg = Release|x64 {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|x64.Build.0 = Release|x64 + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|Any CPU.Build.0 = Release|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|x86.ActiveCfg = Release|Any CPU + {102FA930-E921-4B94-BDA6-9D4F16D829AE}.Release|x86.Build.0 = Release|Any CPU {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|x64.ActiveCfg = Debug|x64 {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|x64.Build.0 = Debug|x64 + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Bounds|x86.Build.0 = Bounds|Any CPU {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|x64.ActiveCfg = Debug|x64 {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|x64.Build.0 = Debug|x64 + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|x86.ActiveCfg = Debug|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Debug|x86.Build.0 = Debug|Any CPU {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|x64.ActiveCfg = Release|x64 {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|x64.Build.0 = Release|x64 + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|Any CPU.Build.0 = Release|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|x86.ActiveCfg = Release|Any CPU + {0B09C5E2-7DD0-46AD-ADA9-00040CDE6F7E}.Release|x86.Build.0 = Release|Any CPU {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|x64.ActiveCfg = Debug|x64 {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|x64.Build.0 = Debug|x64 + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Bounds|x86.Build.0 = Bounds|Any CPU {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|x64.ActiveCfg = Debug|x64 {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|x64.Build.0 = Debug|x64 + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|x86.ActiveCfg = Debug|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Debug|x86.Build.0 = Debug|Any CPU {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|x64.ActiveCfg = Release|x64 {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|x64.Build.0 = Release|x64 + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|Any CPU.Build.0 = Release|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|x86.ActiveCfg = Release|Any CPU + {FE322FAD-5208-4FB3-8E71-C8C09CDC3575}.Release|x86.Build.0 = Release|Any CPU {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|x64.ActiveCfg = Debug|x64 {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|x64.Build.0 = Debug|x64 + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|Any CPU.ActiveCfg = Bounds|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|Any CPU.Build.0 = Bounds|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|x86.ActiveCfg = Bounds|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Bounds|x86.Build.0 = Bounds|Any CPU {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|x64.ActiveCfg = Debug|x64 {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|x64.Build.0 = Debug|x64 + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|x86.ActiveCfg = Debug|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Debug|x86.Build.0 = Debug|Any CPU {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|x64.ActiveCfg = Release|x64 {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|x64.Build.0 = Release|x64 + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|Any CPU.Build.0 = Release|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|x86.ActiveCfg = Release|Any CPU + {1D9F7F7D-F4DE-43DC-9E1D-9D0E512D1CB6}.Release|x86.Build.0 = Release|Any CPU {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|x64.ActiveCfg = Debug|x64 {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|x64.Build.0 = Debug|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|Any CPU.ActiveCfg = Bounds|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|Any CPU.Build.0 = Bounds|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|x86.ActiveCfg = Bounds|Win32 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Bounds|x86.Build.0 = Bounds|Win32 {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|x64.ActiveCfg = Debug|x64 {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|x64.Build.0 = Debug|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|Any CPU.ActiveCfg = Debug|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|Any CPU.Build.0 = Debug|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|x86.ActiveCfg = Debug|Win32 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Debug|x86.Build.0 = Debug|Win32 {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|x64.ActiveCfg = Release|x64 {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|x64.Build.0 = Release|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|Any CPU.ActiveCfg = Release|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|Any CPU.Build.0 = Release|x64 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|x86.ActiveCfg = Release|Win32 + {1D4CC42D-BC16-4EC3-A89B-173798828F56}.Release|x86.Build.0 = Release|Win32 {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|x64.ActiveCfg = Debug|x64 {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|x64.Build.0 = Debug|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|Any CPU.ActiveCfg = Bounds|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|Any CPU.Build.0 = Bounds|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|x86.ActiveCfg = Bounds|Win32 + {C644C392-FB14-4DF1-9989-897E182D3849}.Bounds|x86.Build.0 = Bounds|Win32 {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|x64.ActiveCfg = Debug|x64 {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|x64.Build.0 = Debug|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|Any CPU.ActiveCfg = Debug|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|Any CPU.Build.0 = Debug|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|x86.ActiveCfg = Debug|Win32 + {C644C392-FB14-4DF1-9989-897E182D3849}.Debug|x86.Build.0 = Debug|Win32 {C644C392-FB14-4DF1-9989-897E182D3849}.Release|x64.ActiveCfg = Release|x64 {C644C392-FB14-4DF1-9989-897E182D3849}.Release|x64.Build.0 = Release|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Release|Any CPU.ActiveCfg = Release|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Release|Any CPU.Build.0 = Release|x64 + {C644C392-FB14-4DF1-9989-897E182D3849}.Release|x86.ActiveCfg = Release|Win32 + {C644C392-FB14-4DF1-9989-897E182D3849}.Release|x86.Build.0 = Release|Win32 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|x64.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|x64.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|Any CPU.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|Any CPU.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|x86.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Bounds|x86.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|x64.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|x64.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|Any CPU.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|Any CPU.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|x86.ActiveCfg = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Debug|x86.Build.0 = Debug|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x64.ActiveCfg = Release|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x64.Build.0 = Release|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|Any CPU.ActiveCfg = Release|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|Any CPU.Build.0 = Release|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x86.ActiveCfg = Release|x64 + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770}.Release|x86.Build.0 = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x64.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x64.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|Any CPU.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|Any CPU.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x86.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Bounds|x86.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|x64.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|x64.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|Any CPU.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|Any CPU.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|x86.ActiveCfg = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Debug|x86.Build.0 = Debug|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|x64.ActiveCfg = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|x64.Build.0 = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|Any CPU.ActiveCfg = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|Any CPU.Build.0 = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|x86.ActiveCfg = Release|x64 + {7422D0D6-724C-4A12-993B-055727523EC8}.Release|x86.Build.0 = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|x64.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|x64.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|Any CPU.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|Any CPU.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|x86.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Bounds|x86.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|x64.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|x64.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|Any CPU.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|Any CPU.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|x86.ActiveCfg = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Debug|x86.Build.0 = Debug|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|x64.ActiveCfg = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|x64.Build.0 = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|Any CPU.ActiveCfg = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|Any CPU.Build.0 = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|x86.ActiveCfg = Release|x64 + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E}.Release|x86.Build.0 = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|x64.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|x64.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|Any CPU.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|Any CPU.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|x86.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Bounds|x86.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|x64.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|x64.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|Any CPU.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|Any CPU.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|x86.ActiveCfg = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Debug|x86.Build.0 = Debug|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|x64.ActiveCfg = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|x64.Build.0 = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|Any CPU.ActiveCfg = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|Any CPU.Build.0 = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|x86.ActiveCfg = Release|x64 + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C}.Release|x86.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1125,6 +2870,11 @@ Global {6D69D131-C928-6A46-F508-A4A608CBE30A} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {8D3F63D3-8EF8-43FD-B5C1-4DF686A242D5} = {6D69D131-C928-6A46-F508-A4A608CBE30A} {8AC88510-3A3E-4AD0-B64D-C83AC81AD8FE} = {6D69D131-C928-6A46-F508-A4A608CBE30A} + {BFF2DF6E-AA54-47C7-211F-79ACCBB4577C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E43B733E-FD49-4B8E-91FE-95DE8CCB2770} = {BFF2DF6E-AA54-47C7-211F-79ACCBB4577C} + {7422D0D6-724C-4A12-993B-055727523EC8} = {BFF2DF6E-AA54-47C7-211F-79ACCBB4577C} + {EDD76559-F4AD-4841-9A26-B1EC3C6E232E} = {BFF2DF6E-AA54-47C7-211F-79ACCBB4577C} + {CBF8161E-DCC7-48C2-A754-74F5EF144E1C} = {BFF2DF6E-AA54-47C7-211F-79ACCBB4577C} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {9F385E4A-ED83-4896-ADB8-335A2065B865} diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index 27ea2ea211..9658f686ca 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -3428,6 +3428,14 @@ public ContextMenu GetSliceContextMenu(Slice slice, bool fHotLinkOnly) return ShowContextMenuEvent(this, e); } + /// + /// True when this tree serves as the HIDDEN command-routing adapter for an externally + /// rendered surface (the approved "command-menu-routing" baseline adapter of the Avalonia + /// lexical-edit migration, tasks 13.4/15.4): display logic that gates on Visible + /// treats the adapter tree as active, since the user-visible surface lives elsewhere. + /// + public bool IsExternalCommandAdapter { get; set; } + /// /// Set the handler which will be invoked when the user right-clicks on the /// TreeNode portion of a slice, or for some other reason we need the context menu. diff --git a/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeReshowTimingTests.cs b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeReshowTimingTests.cs new file mode 100644 index 0000000000..59237efffe --- /dev/null +++ b/Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeReshowTimingTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using Newtonsoft.Json; +using NUnit.Framework; +using SIL.FieldWorks.Common.RenderVerification; +using SIL.LCModel; +using SIL.LCModel.Core.Text; + +namespace SIL.FieldWorks.Common.Framework.DetailControls +{ + /// + /// Task 2.13 (refresh-after-edit lane): measures the legacy DataTree's re-show cost — a second + /// ShowObject on the live tree after a model edit, which exercises the slice-reuse + /// (ObjSeqHashMap) refresh path RecordEditView drives on record navigation and refresh. Numbers + /// accumulate into the same Output/RenderBenchmarks/datatree-timings.json artifact the + /// entry-open baselines use and feed region-manifest §5. + /// + [TestFixture] + public class DataTreeReshowTimingTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + [Test] + public void ReshowAfterEdit_OnLiveDataTree_IsMeasuredAndRecorded() + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("reshow-entry", Cache.DefaultVernWs)); + var senseFactory = Cache.ServiceLocator.GetInstance(); + for (var i = 0; i < 3; i++) + { + var sense = senseFactory.Create(); + entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString($"gloss {i}", Cache.DefaultAnalWs)); + } + + using (var harness = new DataTreeRenderHarness(Cache, entry)) + { + harness.PopulateSlices(); + var openMs = harness.LastTiming.PopulateSlicesMs; + var sliceCount = harness.SliceCount; + Assert.That(sliceCount, Is.GreaterThan(0)); + + // The edit a refresh would follow. + entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("edited", Cache.DefaultVernWs)); + + // Refresh on the live tree: RefreshList(false) is the rebuild path legacy refresh + // drives for the same object (a same-root ShowObject early-outs at DataTree.cs:1073). + var stopwatch = Stopwatch.StartNew(); + harness.DataTree.RefreshList(false); + stopwatch.Stop(); + var reshowMs = stopwatch.Elapsed.TotalMilliseconds; + + TestContext.WriteLine( + $"[DATATREE-TIMING] open={openMs:F1}ms reshow-after-edit={reshowMs:F1}ms slices={sliceCount}"); + Assert.That(reshowMs, Is.GreaterThanOrEqualTo(0)); + + RecordTiming("timing-reshow-after-edit", sliceCount, openMs, reshowMs); + } + } + + private static void RecordTiming(string scenario, int slices, double openMs, double reshowMs) + { + var outputDir = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "..", "..", "Output", "RenderBenchmarks"); + Directory.CreateDirectory(outputDir); + var filePath = Path.Combine(outputDir, "datatree-timings.json"); + + Dictionary allTimings = null; + if (File.Exists(filePath)) + allTimings = JsonConvert.DeserializeObject>(File.ReadAllText(filePath)); + allTimings = allTimings ?? new Dictionary(); + + allTimings[scenario] = new + { + slices, + openMs = Math.Round(openMs, 1), + reshowAfterEditMs = Math.Round(reshowMs, 1), + timestamp = DateTime.UtcNow.ToString("o") + }; + File.WriteAllText(filePath, JsonConvert.SerializeObject(allTimings, Formatting.Indented)); + } + } +} diff --git a/Src/Common/FieldWorks/App.config b/Src/Common/FieldWorks/App.config index 15fbb6b478..2071651806 100644 --- a/Src/Common/FieldWorks/App.config +++ b/Src/Common/FieldWorks/App.config @@ -29,6 +29,17 @@ Only entries that auto-gen cannot produce are kept below. + + + + + diff --git a/Src/Common/FieldWorks/FieldWorks.cs b/Src/Common/FieldWorks/FieldWorks.cs index 904b05b73a..b6c385bb5b 100644 --- a/Src/Common/FieldWorks/FieldWorks.cs +++ b/Src/Common/FieldWorks/FieldWorks.cs @@ -3865,6 +3865,25 @@ private static void ExitCleanly() { DataUpdateMonitor.ClearSemaphore(); + // Defense in depth: an undo task somebody left open (e.g. an editing surface that + // failed to close its fenced session) would make the shutdown Save() throw "Commit + // at wrong place." and lose ALL unsaved work. Roll the leaked task back HERE, on + // the UI thread that owns the UOW write lock — CommitAndDisposeCache may run on the + // progress dialog's worker thread, where the rollback would be rejected. + var actionHandler = s_cache.ActionHandlerAccessor; + if (actionHandler.CurrentDepth > 0) + { + Logger.WriteEvent("Shutdown found an undo task still open; rolling it back so the save can proceed."); + try + { + actionHandler.Rollback(0); + } + catch (Exception e) + { + Logger.WriteError(e); + } + } + using (var progressDlg = new ProgressDialogWithTask(s_threadHelper)) { progressDlg.Title = string.Format(ResourceHelper.GetResourceString("kstidShutdownCaption"), diff --git a/Src/Common/FwAvalonia/FwAvalonia.csproj b/Src/Common/FwAvalonia/FwAvalonia.csproj index cc56071933..998ea4fb4f 100644 --- a/Src/Common/FwAvalonia/FwAvalonia.csproj +++ b/Src/Common/FwAvalonia/FwAvalonia.csproj @@ -1,24 +1,29 @@ net48 + + FwAvalonia latest disable false - false false $(NoWarn);CS1591;NU1701 @@ -29,6 +34,8 @@ + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs new file mode 100644 index 0000000000..deee83c4be --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -0,0 +1,43 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Resources; + +namespace SIL.FieldWorks.Common.FwAvalonia +{ + /// + /// Localized product-facing strings for the Avalonia lexical-edit surfaces (task 6.11). Standard + /// .resx-backed resources (Crowdin-compatible); automation ids remain nonlocalized constants in + /// code, never resource lookups. + /// + public static class FwAvaloniaStrings + { + private static readonly ResourceManager Resources = + new ResourceManager("FwAvalonia.FwAvaloniaStrings", typeof(FwAvaloniaStrings).Assembly); + + public static string NoEntrySelected => Resources.GetString("ksNoEntrySelected"); + + public static string EntryTypeUnsupported => Resources.GetString("ksEntryTypeUnsupported"); + + public static string UnsupportedEditor => Resources.GetString("ksUnsupportedEditor"); + + public static string Save => Resources.GetString("ksSave"); + + public static string Cancel => Resources.GetString("ksCancel"); + + public static string UndoEditEntry => Resources.GetString("ksUndoEditEntry"); + + public static string RedoEditEntry => Resources.GetString("ksRedoEditEntry"); + + public static string LexemeFormRequired => Resources.GetString("ksLexemeFormRequired"); + + public static string LexicalEditRegionName => Resources.GetString("ksLexicalEditRegionName"); + + public static string AvaloniaHostName => Resources.GetString("ksAvaloniaHostName"); + + public static string GhostAddPromptFormat => Resources.GetString("ksGhostAddPrompt"); + + public static string Copy => Resources.GetString("ksCopy"); + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx new file mode 100644 index 0000000000..ffd114a887 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -0,0 +1,63 @@ + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + No lexical entry selected. + Placeholder shown in the Avalonia lexical-edit surface when no record is selected. + + + The new lexical edit view is currently available only for lexical entry records. Other record types use the classic view. + Shown when the Avalonia lexical-edit surface is asked to display a non-LexEntry record. + + + (this field type is not supported in the new view yet) + Shown in place of a field whose editor has no Avalonia renderer. + + + Save + Commit button of the Avalonia lexical-edit region editor. + + + Cancel + Cancel button of the Avalonia lexical-edit region editor. + + + Undo Edit Entry + Undo label for the fenced lexical-edit session; appears in the Edit menu. + + + Redo Edit Entry + Redo label for the fenced lexical-edit session; appears in the Edit menu. + + + A Lexeme Form (or Citation Form) is required. + Validation message when an entry would be saved without any lexeme/citation form text. + + + Lexical Edit Region + Screen-reader name of the Avalonia lexical-edit region surface. + + + Lexical Edit (new view) + Screen-reader name of the WinForms control hosting the Avalonia surface. + + + Click here to add {0}. + Ghost add-prompt line shown for an empty field section; {0} is the field label. + + + Copy + Context-menu command copying a field's text. + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs new file mode 100644 index 0000000000..fc8450162a --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs @@ -0,0 +1,258 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Task 7.1 — the virtualized browse/table path: a 10k-row source realizes only visible rows and + /// materializes only realized cells. + /// + [TestFixture] + public class LexicalBrowseViewTests + { + private sealed class CountingRowSource : IBrowseRowSource + { + public int Materialized; + + public int RowCount => 10_000; + + public IReadOnlyList GetCellValues(int rowIndex) + { + Materialized++; + return new[] { $"lexeme {rowIndex}", $"gloss {rowIndex}" }; + } + } + + private static ViewDefinitionModel TwoColumnDefinition() => new ViewDefinitionModel( + "LexEntry", "browse", "browse", + new List + { + new ViewNode("b/#0", ViewNodeKind.Field, "Lexeme Form", null, "Form", "multistring", + EditorClassification.Known, "vernacular", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null), + new ViewNode("b/#1", ViewNodeKind.Field, "Gloss", null, "Gloss", "multistring", + EditorClassification.Known, "analysis", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null) + }, + new List()); + + [AvaloniaTest] + public void TenThousandRows_RealizeOnlyTheVisibleWindow() + { + var source = new CountingRowSource(); + var view = new LexicalBrowseView(TwoColumnDefinition(), source); + var window = new Window { Content = view, Width = 480, Height = 320 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + var realized = view.GetVisualDescendants().OfType().Count(); + Assert.That(realized, Is.GreaterThan(0), "visible rows realize"); + Assert.That(realized, Is.LessThan(100), $"virtualization must cap realization (realized {realized} of 10000)"); + Assert.That(source.Materialized, Is.LessThan(300), + $"cells materialize only for realized rows (materialized {source.Materialized} of 10000)"); + + var header = view.GetVisualDescendants().OfType() + .FirstOrDefault(t => Avalonia.Automation.AutomationProperties.GetAutomationId(t) == "BrowseHeader.Form"); + Assert.That(header?.Text, Is.EqualTo("Lexeme Form"), "columns come from the typed definition"); + } + } + + /// + /// Tasks 9.2/9.4 — canonical JSON: the typed IR round-trips losslessly (snapshot-identical), the + /// shipped first slice serializes and reloads with runtime XML fully out of the loop, and version + /// mismatches are rejected rather than guessed. + /// + [TestFixture] + public class ViewDefinitionJsonSerializerTests + { + private static string ShippedPartsDirectory() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null); + return Path.Combine(dir.FullName, "DistFiles", "Language Explorer", "Configuration", "Parts"); + } + + [Test] + public void CompiledFirstSlice_RoundTripsThroughCanonicalJson_SnapshotIdentical() + { + var compiled = LexicalEditFirstSlice.CompileFromLayoutDirectory(ShippedPartsDirectory()); + Assert.That(compiled, Is.Not.Null); + + var json = ViewDefinitionJsonSerializer.Serialize(compiled); + var reloaded = ViewDefinitionJsonSerializer.Deserialize(json); + + Assert.That(reloaded.ToSnapshot(), Is.EqualTo(compiled.ToSnapshot()), + "the canonical JSON lane must be lossless against the semantic snapshot"); + Assert.That(json, Does.Contain("\"formatVersion\": 1")); + } + + [Test] + public void Serialization_IsDeterministic() + { + var compiled = LexicalEditFirstSlice.AuthoredFallback(); + Assert.That(ViewDefinitionJsonSerializer.Serialize(compiled), + Is.EqualTo(ViewDefinitionJsonSerializer.Serialize(LexicalEditFirstSlice.AuthoredFallback()))); + } + + [Test] + public void UnsupportedFormatVersion_IsRejected() + { + Assert.That(() => ViewDefinitionJsonSerializer.Deserialize("{\"formatVersion\": 99, \"nodes\": []}"), + Throws.InstanceOf()); + } + + [Test] + public void EveryViewNodeProperty_SurvivesRoundTrip() + { + // Every ViewNode property set to a non-default value so any silently dropped field fails. + var child = new ViewNode( + "n/#0/#0", ViewNodeKind.Field, "Child Label", "ch", "Gloss", "multistring", + EditorClassification.Known, "analysis", ViewVisibility.Always, ViewExpansion.NotApplicable, + false, null, null); + var node = new ViewNode( + stableId: "n/#0", + kind: ViewNodeKind.Sequence, + label: "Senses", + abbreviation: "sns", + field: "Senses", + rawEditor: "seq", + editorClassification: EditorClassification.Known, + writingSystem: "vernacular", + visibility: ViewVisibility.IfData, + expansion: ViewExpansion.Expanded, + indented: true, + targetLayout: "detail", + children: new List { child }, + localizationKey: "ksSenses", + automationId: "Entry.Senses", + routing: SurfaceRouting.Product, + boldEmphasis: true, + fontScalePercent: 120, + menuId: "mnuDataTree-Sense", + contextMenuId: "mnuDataTree-SenseContext", + hotlinksId: "mnuDataTree-Sense-Hotlinks", + ghostField: "Gloss", + ghostWs: "analysis", + ghostClass: "LexSense", + ghostLabel: "Gloss", + ghostInitMethod: "SetMorphTypeToRoot", + condition: new ViewCondition( + negated: true, + target: "owner", + isClass: "CmPossibilityList", + excludeSubclasses: true, + field: "Depth", + boolEquals: true, + intEquals: 1, + intLessThan: 2, + intGreaterThan: 3, + intMemberOf: "2,3,7", + lengthAtLeast: 1, + lengthAtMost: 4, + guidEquals: "d7f713da-e8cf-11d3-9764-00c04f186933")); + var model = new ViewDefinitionModel("LexEntry", "Normal", "detail", + new List { node }, new List()); + + var reloaded = ViewDefinitionJsonSerializer.Deserialize(ViewDefinitionJsonSerializer.Serialize(model)); + var r = reloaded.Roots[0]; + + Assert.Multiple(() => + { + Assert.That(r.StableId, Is.EqualTo("n/#0"), nameof(r.StableId)); + Assert.That(r.Kind, Is.EqualTo(ViewNodeKind.Sequence), nameof(r.Kind)); + Assert.That(r.Label, Is.EqualTo("Senses"), nameof(r.Label)); + Assert.That(r.Abbreviation, Is.EqualTo("sns"), nameof(r.Abbreviation)); + Assert.That(r.Field, Is.EqualTo("Senses"), nameof(r.Field)); + Assert.That(r.RawEditor, Is.EqualTo("seq"), nameof(r.RawEditor)); + Assert.That(r.EditorClassification, Is.EqualTo(EditorClassification.Known), nameof(r.EditorClassification)); + Assert.That(r.WritingSystem, Is.EqualTo("vernacular"), nameof(r.WritingSystem)); + Assert.That(r.Visibility, Is.EqualTo(ViewVisibility.IfData), nameof(r.Visibility)); + Assert.That(r.Expansion, Is.EqualTo(ViewExpansion.Expanded), nameof(r.Expansion)); + Assert.That(r.Indented, Is.True, nameof(r.Indented)); + Assert.That(r.TargetLayout, Is.EqualTo("detail"), nameof(r.TargetLayout)); + Assert.That(r.Children, Has.Count.EqualTo(1), nameof(r.Children)); + Assert.That(r.Children[0].StableId, Is.EqualTo("n/#0/#0"), "child StableId"); + Assert.That(r.LocalizationKey, Is.EqualTo("ksSenses"), nameof(r.LocalizationKey)); + Assert.That(r.AutomationId, Is.EqualTo("Entry.Senses"), nameof(r.AutomationId)); + Assert.That(r.Routing, Is.EqualTo(SurfaceRouting.Product), nameof(r.Routing)); + Assert.That(r.BoldEmphasis, Is.True, nameof(r.BoldEmphasis)); + Assert.That(r.FontScalePercent, Is.EqualTo(120), nameof(r.FontScalePercent)); + Assert.That(r.MenuId, Is.EqualTo("mnuDataTree-Sense"), nameof(r.MenuId)); + Assert.That(r.ContextMenuId, Is.EqualTo("mnuDataTree-SenseContext"), nameof(r.ContextMenuId)); + Assert.That(r.HotlinksId, Is.EqualTo("mnuDataTree-Sense-Hotlinks"), nameof(r.HotlinksId)); + Assert.That(r.GhostField, Is.EqualTo("Gloss"), nameof(r.GhostField)); + Assert.That(r.GhostWs, Is.EqualTo("analysis"), nameof(r.GhostWs)); + Assert.That(r.GhostClass, Is.EqualTo("LexSense"), nameof(r.GhostClass)); + Assert.That(r.GhostLabel, Is.EqualTo("Gloss"), nameof(r.GhostLabel)); + Assert.That(r.GhostInitMethod, Is.EqualTo("SetMorphTypeToRoot"), nameof(r.GhostInitMethod)); + Assert.That(r.Condition, Is.Not.Null, nameof(r.Condition)); + Assert.That(r.Condition.Negated, Is.True, "Condition.Negated"); + Assert.That(r.Condition.Target, Is.EqualTo("owner"), "Condition.Target"); + Assert.That(r.Condition.IsClass, Is.EqualTo("CmPossibilityList"), "Condition.IsClass"); + Assert.That(r.Condition.ExcludeSubclasses, Is.True, "Condition.ExcludeSubclasses"); + Assert.That(r.Condition.Field, Is.EqualTo("Depth"), "Condition.Field"); + Assert.That(r.Condition.BoolEquals, Is.True, "Condition.BoolEquals"); + Assert.That(r.Condition.IntEquals, Is.EqualTo(1), "Condition.IntEquals"); + Assert.That(r.Condition.IntLessThan, Is.EqualTo(2), "Condition.IntLessThan"); + Assert.That(r.Condition.IntGreaterThan, Is.EqualTo(3), "Condition.IntGreaterThan"); + Assert.That(r.Condition.IntMemberOf, Is.EqualTo("2,3,7"), "Condition.IntMemberOf"); + Assert.That(r.Condition.LengthAtLeast, Is.EqualTo(1), "Condition.LengthAtLeast"); + Assert.That(r.Condition.LengthAtMost, Is.EqualTo(4), "Condition.LengthAtMost"); + Assert.That(r.Condition.GuidEquals, Is.EqualTo("d7f713da-e8cf-11d3-9764-00c04f186933"), + "Condition.GuidEquals"); + }); + } + } + + /// + /// Task 9.3 (override-fixture lane): user-override-shaped layout XML — label/visibility overrides + /// and a hidden part — imports with the overrides surfaced in the typed IR. + /// + [TestFixture] + public class OverrideFixtureImportTests + { + private const string PartsXml = @" + + + + + + + +"; + + [Test] + public void UserOverrides_LabelRenameHideAndReorder_SurfaceInTheIR() + { + // The shape Inventory.PersistOverrideElement stores: a full layout copy with user edits. + var overridden = @" + + + +"; + var parts = new DictionaryPartResolver(System.Xml.Linq.XElement.Parse(PartsXml)); + var model = new XmlLayoutImporter().Import(System.Xml.Linq.XElement.Parse(overridden), parts); + + Assert.That(model.LayoutName, Is.EqualTo("Normal%01"), "user override layout names import"); + Assert.That(model.Roots[0].Field, Is.EqualTo("Bibliography"), "user reorder is honored"); + Assert.That(model.Roots[0].Label, Is.EqualTo("Sources"), "user label rename overrides the part label"); + Assert.That(model.Roots[1].Visibility, Is.EqualTo(ViewVisibility.Never), "user hide is honored"); + + // And the override round-trips the canonical JSON lane too. + var reloaded = ViewDefinitionJsonSerializer.Deserialize(ViewDefinitionJsonSerializer.Serialize(model)); + Assert.That(reloaded.ToSnapshot(), Is.EqualTo(model.ToSnapshot())); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs new file mode 100644 index 0000000000..8f768a2766 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; + +namespace FwAvaloniaTests +{ + /// + /// Tasks 5.5/5.8 (and the runtime half of 8.4): the region-manifest engine-isolation audit. The + /// migrated Avalonia path must carry no dependency on native Views rendering, native render + /// engines (Graphite/Uniscribe), Gecko/browser engines, or legacy view stacks — at the assembly + /// level (what the production assembly can even load) and at the source level (what production + /// code names). A failure of either test blocks Avalonia default readiness by construction. + /// + [TestFixture] + public class EngineIsolationAuditTests + { + // Assembly names the production FwAvalonia assembly must never reference. + // System.Windows.Forms is the single approved adapter exception: WinFormsAvaloniaControlHost + // (in-process hosting) requires it; it hosts the surface and owns no rendering of user text. + private static readonly string[] ForbiddenAssemblyFragments = + { + "Graphite", "ViewsInterfaces", "RootSite", "SimpleRootSite", "Gecko", "Geckofx", + "SIL.LCModel", "xCore", "XMLViews", "DetailControls" + }; + + // Identifiers from the region-manifest forbidden-symbol list that production source must not + // name (native Views render/editor pipeline, native render engines, browser/PDF engines). + private static readonly string[] ForbiddenSourceSymbols = + { + "IVwRootBox", "IVwEnv", "IVwGraphics", "VwRootBox", "ManagedVwWindow", + "IRenderEngine", "IRenderEngineFactory", "GraphiteEngineClass", "UniscribeEngineClass", + "FwGrEngine", "GraphiteSegment", "RootSiteControl", + "GeckoWebBrowser", "XWebBrowser", "GeckofxHtmlToPdf", "FieldWorksPdfMaker" + }; + + [Test] + public void ProductionAssembly_ReferencesNoNativeRenderLegacyOrDomainAssemblies() + { + var referenced = typeof(LexicalEditRegionView).Assembly.GetReferencedAssemblies() + .Select(r => r.Name) + .ToList(); + + var violations = referenced + .Where(name => ForbiddenAssemblyFragments.Any(bad => name.IndexOf(bad, StringComparison.OrdinalIgnoreCase) >= 0)) + .ToList(); + + Assert.That(violations, Is.Empty, + "FwAvalonia must stay free of native-render/legacy/domain assembly references; found: " + + string.Join(", ", violations)); + } + + [Test] + public void ProductionSource_NamesNoForbiddenNativeRenderSymbols() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "could not locate the repo root"); + + var productionRoot = Path.Combine(dir.FullName, "Src", "Common", "FwAvalonia"); + var testRoot = Path.Combine(productionRoot, "FwAvaloniaTests"); + var sources = Directory.GetFiles(productionRoot, "*.cs", SearchOption.AllDirectories) + .Where(path => !path.StartsWith(testRoot, StringComparison.OrdinalIgnoreCase)) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + Assert.That(sources, Is.Not.Empty); + + var violations = new List(); + foreach (var path in sources) + { + var text = File.ReadAllText(path); + foreach (var symbol in ForbiddenSourceSymbols) + { + // Whole-identifier match so e.g. a comment mentioning "Views" broadly is fine but + // naming the actual forbidden type is not (even in a comment, naming the native + // pipeline in production source is a smell the audit surfaces for review). + if (Regex.IsMatch(text, $@"\b{Regex.Escape(symbol)}\b")) + violations.Add($"{Path.GetFileName(path)}: {symbol}"); + } + } + + Assert.That(violations, Is.Empty, + "FwAvalonia production source must not name native Views/render-engine/browser symbols; found: " + + string.Join("; ", violations)); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj index 7ce477c73d..abd586f8f0 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwAvaloniaTests.csproj @@ -1,11 +1,14 @@ + + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwClipboardSeamTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwClipboardSeamTests.cs new file mode 100644 index 0000000000..5e9a926366 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwClipboardSeamTests.cs @@ -0,0 +1,42 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Seams; + +namespace FwAvaloniaTests +{ + /// Task 3.13 — the LCModel-free clipboard seam contract and its in-memory implementation. + [TestFixture] + public class FwClipboardSeamTests + { + [Test] + public void InMemoryClipboard_StartsEmpty() + { + var clipboard = new InMemoryFwClipboard(); + Assert.That(clipboard.ContainsText(), Is.False); + Assert.That(clipboard.GetText(), Is.Null); + } + + [Test] + public void InMemoryClipboard_RoundTripsBothLanes() + { + var clipboard = new InMemoryFwClipboard(); + clipboard.SetText(new FwClipboardText("plain", "plain")); + + var payload = clipboard.GetText(); + Assert.That(clipboard.ContainsText(), Is.True); + Assert.That(payload.PlainText, Is.EqualTo("plain")); + Assert.That(payload.RichXml, Does.Contain("Run")); + } + + [Test] + public void Payload_PlainLaneIsNeverNull_RichLaneIsOptional() + { + var payload = new FwClipboardText(null); + Assert.That(payload.PlainText, Is.EqualTo(string.Empty)); + Assert.That(payload.RichXml, Is.Null); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs new file mode 100644 index 0000000000..39f6cabd5f --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LayoutImportCoverageTests.cs @@ -0,0 +1,831 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Task 4.9: the importer must report every silently dropped layout construct/attribute as a + /// diagnostic, and importer coverage over the shipped layout files must be a measured number. + /// + [TestFixture] + public class XmlLayoutImporterDropDiagnosticsTests + { + private const string PartsXml = @" + + + + + + + + + + + + + + + + + + + + + +"; + + private static ViewDefinitionModel Import(string layoutXml) + { + var parts = new DictionaryPartResolver(XElement.Parse(PartsXml)); + return new XmlLayoutImporter().Import(XElement.Parse(layoutXml), parts); + } + + [Test] + public void UnhandledFunctionalAttribute_OnCallerPart_RaisesWarning() + { + var model = Import(@" + + +"); + + var diag = model.Diagnostics.Single(d => d.Code == "unhandled-attribute"); + Assert.That(diag.Severity, Is.EqualTo(ViewDiagnosticSeverity.Warning), "ghost wiring is functional"); + Assert.That(diag.Message, Does.Contain("'ghostInitMethod'")); + } + + [Test] + public void UnhandledPresentationalAttribute_OnSliceContent_RaisesInfo() + { + var model = Import(@" + + +"); + + var dropped = model.Diagnostics.Where(d => d.Code == "unhandled-attribute").ToList(); + Assert.That(dropped.Select(d => d.Severity), Is.All.EqualTo(ViewDiagnosticSeverity.Info)); + Assert.That(dropped.Count, Is.EqualTo(3), "style, before, after"); + } + + [Test] + public void MenuAndHotlinks_OnSliceContent_AreImported_NotDropped() + { + // Section 13.1 superseded the old drop-warning: menu bindings now land on the node. + var model = Import(@" + + +"); + + Assert.That(model.Diagnostics.Where(d => d.Code == "unhandled-attribute"), Is.Empty, + "menu/hotlinks are handled attributes since 13.1"); + Assert.That(model.Roots[0].MenuId, Is.EqualTo("mnuDataTree-VariantForms")); + Assert.That(model.Roots[0].HotlinksId, Is.EqualTo("mnuDataTree-VariantForms-Hotlinks")); + } + + [Test] + public void GenerateElement_RaisesNamedDropDiagnostic() + { + var model = Import(@" + + +"); + + var diag = model.Diagnostics.Single(d => d.Code == "generated-content-dropped"); + Assert.That(diag.Severity, Is.EqualTo(ViewDiagnosticSeverity.Warning)); + } + + [Test] + public void ConditionalElements_WithSubstitutionValues_StillDropWithDiagnostics() + { + // B3 imports supported conditionals; a $-substituted condition value (the + // custom-field shape) still needs runtime substitution (B9), so it keeps the drop lane. + var model = Import(@" + + + +"); + + Assert.That(model.Diagnostics.Count(d => d.Code == "conditional-dropped"), Is.EqualTo(2)); + Assert.That(model.Roots, Is.Empty, "an unevaluable condition must not import its content"); + } + + [Test] + public void SublayoutElement_RaisesInfoDropDiagnostic() + { + var model = Import(@" + + +"); + + Assert.That(model.Diagnostics.Single(d => d.Code == "sublayout-dropped").Severity, + Is.EqualTo(ViewDiagnosticSeverity.Info)); + } + + [Test] + public void CallerChildren_IndentAndPart_UnderSliceContentPart_AreImportedAsChildren() + { + // Mirrors the real AsLexemeFormBasic shape: a slice-content part whose caller nests + // . DataTree realizes these as indented child slices, so the + // importer must too (task 4.10). + var model = Import(@" + + + + +"); + + Assert.That(model.Diagnostics.Any(d => d.Code == "caller-children-dropped"), Is.False); + var section = model.Roots.Single(); + Assert.That(section.Children.Count, Is.EqualTo(1)); + Assert.That(section.Children[0].Field, Is.EqualTo("CitationForm")); + Assert.That(section.Children[0].Indented, Is.True); + } + + [Test] + public void CallerChildren_OfOtherKinds_UnderSliceContentPart_AreStillReported() + { + var model = Import(@" + + + + +"); + + Assert.That(model.Diagnostics.Any(d => d.Code == "caller-children-dropped"), Is.True, + "non-structural caller children must still be reported, not silently dropped"); + } + + [Test] + public void NonPartCallerChildren_UnderSequencePart_AreReported() + { + var model = Import(@" + + + + +"); + + Assert.That(model.Diagnostics.Any(d => d.Code == "injected-child-dropped"), Is.True); + } + + [Test] + public void SliceContentChildren_OtherThanStructural_AreReported() + { + var model = Import(@" + + +"); + + var diag = model.Diagnostics.Single(d => d.Code == "slice-content-dropped"); + Assert.That(diag.Message, Does.Contain("deParams")); + } + + [Test] + public void SubstitutionValues_InHandledAttributes_AreReported() + { + var model = Import(@" + + +"); + + var diag = model.Diagnostics.Single(d => d.Code == "param-substitution"); + Assert.That(diag.Message, Does.Contain("$ws=vernacular")); + } + + [Test] + public void CleanLayout_StillProducesNoDiagnostics() + { + var model = Import(@" + + +"); + + Assert.That(model.Diagnostics, Is.Empty, "drop diagnostics must not add noise to clean layouts"); + } + } + + /// + /// B3 (xml-retirement-blockers): legacy <if>/<ifnot>/<choice> + /// import as typed Conditional/ChoiceGroup nodes carrying structured ViewCondition metadata — + /// the condition forms the shipped detail layouts use (boolequals/intequals/intlessthan/ + /// intmemberof/lengthatleast/lengthatmost/guidequals/is/target) — and round-trip canonical JSON. + /// Unsupported (publishing-lane) forms keep the conditional-dropped lane. + /// + [TestFixture] + public class ConditionalImportTests + { + private const string PartsXml = @" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +"; + + private static ViewDefinitionModel Import(string className, string refName) + { + var parts = new DictionaryPartResolver(XElement.Parse(PartsXml)); + return new XmlLayoutImporter().Import(XElement.Parse( + $""), parts); + } + + [Test] + public void If_ImportsConditionalNode_WithStructuredIntEqualsCondition() + { + var model = Import("LexEntryRef", "VariantEntryTypes"); + + Assert.That(model.Diagnostics.Where(d => d.Code == "conditional-dropped"), Is.Empty); + var node = model.Roots.Single(); + Assert.That(node.Kind, Is.EqualTo(ViewNodeKind.Conditional)); + Assert.That(node.Condition.Negated, Is.False); + Assert.That(node.Condition.Field, Is.EqualTo("RefType")); + Assert.That(node.Condition.IntEquals, Is.EqualTo(0)); + Assert.That(node.Children.Single().Field, Is.EqualTo("VariantEntryTypes"), + "the conditional's content imports as child nodes"); + } + + [Test] + public void If_WithLengthAtLeast_Imports() + { + var node = Import("LexEntry", "ShowMinorEntry").Roots.Single(); + Assert.That(node.Condition.LengthAtLeast, Is.EqualTo(1)); + Assert.That(node.Condition.Field, Is.EqualTo("EntryRefs")); + Assert.That(node.Children.Single().Field, Is.EqualTo("PublishAsMinorEntry")); + } + + [Test] + public void Ifnot_SetsNegated_AndParsesBoolEquals() + { + var node = Import("MoForm", "NotAbstract").Roots.Single(); + Assert.That(node.Kind, Is.EqualTo(ViewNodeKind.Conditional)); + Assert.That(node.Condition.Negated, Is.True); + Assert.That(node.Condition.BoolEquals, Is.True); + Assert.That(node.Condition.Field, Is.EqualTo("IsAbstract")); + } + + [Test] + public void If_WithTargetOwner_AndIntMemberOf_Import() + { + var owner = Import("MoAffixAllomorph", "MsEnvFeatures").Roots.Single(); + Assert.That(owner.Condition.Target, Is.EqualTo("owner")); + Assert.That(owner.Condition.LengthAtLeast, Is.EqualTo(1)); + + var memberOf = Import("LexRefType", "ReverseName").Roots.Single(); + Assert.That(memberOf.Condition.IntMemberOf, Is.EqualTo("2,3,7,8,12,13")); + } + + [Test] + public void Choice_ImportsChoiceGroup_WithWhereAndOtherwiseBranches() + { + var node = Import("MoAffixAllomorph", "AsPosition").Roots.Single(); + + Assert.That(node.Kind, Is.EqualTo(ViewNodeKind.ChoiceGroup)); + Assert.That(node.Children.Count, Is.EqualTo(2)); + Assert.That(node.Children[0].Kind, Is.EqualTo(ViewNodeKind.Conditional)); + Assert.That(node.Children[0].Condition.GuidEquals, Is.EqualTo("D7F713DA-E8CF-11D3-9764-00C04F186933")); + Assert.That(node.Children[0].Children.Single().Label, Is.EqualTo("Infix Positions")); + Assert.That(node.Children[1].Condition, Is.Null, "otherwise = unconditioned branch"); + Assert.That(node.Children[1].Children.Single().Label, Is.EqualTo("Fallback")); + } + + [Test] + public void UnsupportedPublishingConditionForm_KeepsTheDropLane() + { + // stringaltequals/ws is publishing-lane vocabulary (XmlVc string tests, all on Jt parts in + // the shipped files); evaluating it wrongly would hide/show the wrong fields, so it drops. + var model = Import("LexSense", "PublishingDropped"); + + Assert.That(model.Roots, Is.Empty); + Assert.That(model.Diagnostics.Single(d => d.Code == "conditional-dropped").Message, + Does.Contain("stringaltequals").Or.Contain("ws")); + } + + [Test] + public void ConditionalNodes_RoundTripCanonicalJson_SnapshotIdentical() + { + foreach (var (cls, refName) in new[] + { + ("LexEntryRef", "VariantEntryTypes"), ("MoForm", "NotAbstract"), + ("MoAffixAllomorph", "AsPosition"), ("MoAffixAllomorph", "MsEnvFeatures"), + ("LexRefType", "ReverseName") + }) + { + var model = Import(cls, refName); + var reloaded = ViewDefinitionJsonSerializer.Deserialize(ViewDefinitionJsonSerializer.Serialize(model)); + // Deserialize intentionally drops import diagnostics, so compare the node tree only. + var withoutDiagnostics = new ViewDefinitionModel( + model.ClassName, model.LayoutName, model.LayoutType, model.Roots, null); + Assert.That(reloaded.ToSnapshot(), Is.EqualTo(withoutDiagnostics.ToSnapshot()), + $"{cls}-{refName}: the snapshot carries cond=[…], so a dropped condition fails here"); + var root = reloaded.Roots.Single(); + var original = model.Roots.Single(); + Assert.That(root.Condition?.ToString(), Is.EqualTo(original.Condition?.ToString())); + Assert.That(root.Condition?.Negated, Is.EqualTo(original.Condition?.Negated)); + } + } + } + + /// + /// B10 (cross-class part resolution): unit cases for the legacy-faithful resolution rules — + /// the metadata-driven base-class walk (DataTree.cs:2444-2461) and case-insensitive part-id + /// lookup (Inventory.GetElementKey lowercases key attrvals, Inventory.cs:1516). + /// + [TestFixture] + public class CrossClassPartResolutionTests + { + private const string MoFormPartsXml = @" + + + + +"; + + [Test] + public void ResolvePart_WalksMultiHopBaseClassChain() + { + // MoAffixAllomorph → MoAffixForm → MoForm: two hops, exactly the LCModel hierarchy. + var map = new Dictionary(StringComparer.Ordinal) + { + { "MoAffixAllomorph", "MoAffixForm" }, + { "MoAffixForm", "MoForm" } + }; + var resolver = new DictionaryPartResolver(XElement.Parse(MoFormPartsXml), map); + + var content = resolver.ResolvePart("MoAffixAllomorph", "detail", "IsAbstractBasic"); + + Assert.That(content, Is.Not.Null); + Assert.That((string)content.Attribute("field"), Is.EqualTo("IsAbstract")); + } + + [Test] + public void ResolvePart_WithoutBaseClassMap_StillFailsAcrossClasses() + { + var resolver = new DictionaryPartResolver(XElement.Parse(MoFormPartsXml)); + + Assert.That(resolver.ResolvePart("MoAffixAllomorph", "detail", "IsAbstractBasic"), Is.Null); + } + + [Test] + public void ResolvePart_IsCaseInsensitive_LikeLegacyInventory() + { + // Legacy Inventory.GetElementKey lowercases every key attrval (Inventory.cs:1516), so part + // id lookup never depends on ref/id casing. + var resolver = new DictionaryPartResolver(XElement.Parse(MoFormPartsXml)); + + Assert.That(resolver.ResolvePart("MoForm", "detail", "isabstractbasic"), Is.Not.Null); + Assert.That(resolver.ResolvePart("moform", "detail", "IsAbstractBasic"), Is.Not.Null); + } + + [Test] + public void BuildBaseClassMap_ParsesMasterModelClassHierarchy() + { + var masterModel = XElement.Parse(@" + + + + + + +"); + + var map = LayoutImportCoverage.BuildBaseClassMap(masterModel); + + Assert.That(map["CmAnthroItem"], Is.EqualTo("CmPossibility")); + Assert.That(map["CmPossibility"], Is.EqualTo("CmObject")); + Assert.That(map.ContainsKey("CmObject"), Is.False, "the root class has no base entry"); + } + } + + /// + /// Task 4.9: runs the importer over every shipped .fwlayout/Parts.xml pair and regenerates the + /// committed coverage report so importer coverage is a tracked number, not an assumption. + /// + [TestFixture] + public class LayoutImportCoverageTests + { + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + { + dir = dir.Parent; + } + + Assert.That(dir, Is.Not.Null, "could not locate the repo root from the test directory"); + return dir.FullName; + } + + private static (List layouts, List parts) LoadShippedFiles(string repoRoot) + { + var partsDir = Path.Combine(repoRoot, "DistFiles", "Language Explorer", "Configuration", "Parts"); + Assert.That(Directory.Exists(partsDir), Is.True, $"missing shipped parts directory: {partsDir}"); + + var layouts = Directory.GetFiles(partsDir, "*.fwlayout") + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => new LayoutSourceFile(Path.GetFileName(f), XElement.Load(f))) + .ToList(); + var parts = Directory.GetFiles(partsDir, "*Parts.xml") + .OrderBy(f => f, StringComparer.Ordinal) + .Select(f => new LayoutSourceFile(Path.GetFileName(f), XElement.Load(f))) + .ToList(); + + Assert.That(layouts, Is.Not.Empty, "no .fwlayout files found"); + Assert.That(parts, Is.Not.Empty, "no *Parts.xml files found"); + return (layouts, parts); + } + + /// + /// Loads the subclass → base class map from the pinned LCModel package's master model, the same + /// hierarchy production resolution walks via the MDC (B10: metadata-driven, not hand-maintained). + /// + private static IReadOnlyDictionary LoadBaseClassMap(string repoRoot) + { + var lcmPackageDir = Path.Combine(repoRoot, "packages", "sil.lcmodel"); + Assert.That(Directory.Exists(lcmPackageDir), Is.True, $"missing LCModel package dir: {lcmPackageDir}"); + + // Prefer the version pinned in Build/SilVersions.props; otherwise the highest restored one. + var pinned = System.Text.RegularExpressions.Regex + .Match(File.ReadAllText(Path.Combine(repoRoot, "Build", "SilVersions.props")), + @"([^<]+)").Groups[1].Value; + var masterModelPath = Path.Combine(lcmPackageDir, pinned, "contentFiles", "MasterLCModel.xml"); + if (!File.Exists(masterModelPath)) + { + masterModelPath = Directory.GetDirectories(lcmPackageDir) + .OrderBy(d => d, StringComparer.Ordinal) + .Select(d => Path.Combine(d, "contentFiles", "MasterLCModel.xml")) + .LastOrDefault(File.Exists); + } + + Assert.That(masterModelPath, Is.Not.Null.And.Property("Length").GreaterThan(0), + "could not locate MasterLCModel.xml in the restored LCModel package"); + var map = LayoutImportCoverage.BuildBaseClassMap(XElement.Load(masterModelPath)); + Assert.That(map["MoStemAllomorph"], Is.EqualTo("MoForm"), "sanity: master model hierarchy parsed"); + return map; + } + + [Test] + public void ShippedLayouts_CoverageReport_IsGeneratedAndMeasured() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + + var report = LayoutImportCoverage.Run(layouts, parts, LoadBaseClassMap(repoRoot)); + + Assert.That(report.DetailLayoutsImported, Is.GreaterThan(0), "no detail layouts were imported"); + Assert.That(report.NodesProduced, Is.GreaterThan(0), "import produced no typed nodes"); + + // The whole point of task 4.9: the gap must be visible. If these start failing because the + // numbers hit zero, the importer has reached full vocabulary coverage — celebrate, then + // tighten the assertions. + TestContext.WriteLine( + $"element coverage {report.ElementCoveragePercent:F1}%, attribute coverage {report.AttributeCoveragePercent:F1}%, " + + $"layouts {report.DetailLayoutsImported}, nodes {report.NodesProduced}"); + + var markdown = report.ToMarkdown(); + Assert.That(markdown, Does.Contain("## Summary")); + + WriteReportArtifacts(repoRoot, markdown); + } + + [Test] + public void ShippedLayouts_EveryDiagnosticCode_IsInTheKnownTaxonomy() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + + var knownCodes = new HashSet(StringComparer.Ordinal) + { + // structural / resolution + "unknown-container-element", "unknown-part-content", "part-without-ref", + "unresolved-part", "cross-object-deferred", + // editors + "dynamic-editor", "obsolete-editor", "unknown-editor", + // task 4.9 drop taxonomy + "unhandled-attribute", "param-substitution", "generated-content-dropped", + "conditional-dropped", "sublayout-dropped", "caller-children-dropped", + "injected-child-dropped", "slice-content-dropped" + }; + + var report = LayoutImportCoverage.Run(layouts, parts, LoadBaseClassMap(repoRoot)); + var unknown = report.DiagnosticsByCode.Keys + .Select(k => k.Substring(0, k.IndexOf(" (", StringComparison.Ordinal))) + .Where(code => !knownCodes.Contains(code)) + .Distinct() + .ToList(); + + Assert.That(unknown, Is.Empty, + "every emitted diagnostic code must be a classified drop, not an accidental one: " + string.Join(", ", unknown)); + } + + /// + /// B10 (cross-class part resolution): with the metadata-driven base-class walk the importer + /// resolves every shipped part ref that legacy DataTree resolves. What remains is EXACTLY the + /// set of layout refs with no {class-chain}-Detail-{ref} part anywhere in the shipped + /// inventories — refs legacy DataTree also silently omits ("Just omit the missing part", + /// DataTree.cs:2455-2457; the detail lane has no PartGenerator). Asserting the exact set keeps + /// both regressions (new unresolved refs) and silent improvements (parts added) visible. + /// + [Test] + public void ShippedLayouts_UnresolvedParts_AreExactlyTheLegacyUnresolvableSet() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var report = LayoutImportCoverage.Run(layouts, parts, LoadBaseClassMap(repoRoot)); + + // Every entry below was verified to have no matching part id (case-insensitive) on the + // class or any base class in the shipped *Parts.xml files; legacy omits them too. Most are + // summary/section header parts that were never shipped, plus the DateCreated/DateModified + // refs Notebook hides (visibility='never') whose parts never existed in the detail lane. + var legacyUnresolvable = new SortedDictionary(StringComparer.Ordinal) + { + { "CmAnthroItem-Summary", 1 }, + { "CmPerson-Role", 1 }, + { "CmPossibility-Summary", 1 }, + { "CmSemanticDomain-Summary", 1 }, + { "FsClosedValue-Summary", 1 }, + { "FsComplexFeature-Message", 1 }, + { "FsComplexValue-Summary", 1 }, + { "FsFeatStruc-Blank", 1 }, + { "FsFeatStrucType-Message", 1 }, + { "FsFeatureSpecification-Summary", 1 }, + { "LexEntry-ImportResidue", 1 }, + { "LexEntryInflType-Summary", 1 }, + { "LexEntryType-Summary", 2 }, + { "LexEtymology-NormalSummary", 1 }, + { "LexExtendedNote-NormalSummary", 1 }, + { "LexPronunciation-MediaFiles", 1 }, + { "LexReference-ShowSingleReference", 1 }, + { "LexSense-HeavySummary", 1 }, + { "LexSense-ImportResidue", 1 }, + { "LexSense-Pictures", 1 }, + { "MoAlloAdhocProhib-Message", 1 }, + { "MoEndoCompound-HeadLast", 2 }, + { "MoExoCompound-ToMsa", 1 }, + { "MoInflAffixSlot-Optional", 1 }, + { "MoInflClass-SubclassesAllA", 1 }, + { "MoMorphAdhocProhib-Message", 1 }, + { "PartOfSpeech-Section", 2 }, + { "PhPhoneme-Codes", 1 }, + { "ReversalIndexEntry-Section", 2 }, + { "RnGenericRec-DateCreated", 12 }, + { "RnGenericRec-DateModified", 12 }, + { "Text-DateCreated", 1 }, + { "Text-DateModified", 1 }, + { "WfiAnalysis-HeavySummary", 3 }, + { "WfiWordform-HeavySummary", 1 } + }; + + Assert.That(report.UnresolvedPartRefs, Is.EquivalentTo(legacyUnresolvable), + "the unresolved-part set changed; if a part was added/renamed update this set, if a " + + "resolution rule regressed fix the resolver (B10 baseline: 63 occurrences, was 259)"); + Assert.That(report.UnresolvedPartRefs.Values.Sum(), Is.EqualTo(63), + "B10 unresolved-part occurrence ceiling"); + } + + /// + /// B10 fixture: the real CmAnthroItem 'default' layout previously raised 9 unresolved-part + /// Errors because all its refs live on the CmPossibility base class. With the metadata map the + /// whole layout imports clean except the constructs other blockers own. + /// + [Test] + public void CmAnthroItemDefaultLayout_ResolvesAllParts_ViaCmPossibilityBaseClass() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "CmAnthroItem", "default"); + + Assert.That(model.Diagnostics.Where(d => d.Code == "unresolved-part"), Is.Empty); + // All 9 refs resolve and produce nodes; SubPossibilities' content imports as a + // typed ChoiceGroup since B3 landed (one empty where branch + the Subitems otherwise). + Assert.That(model.Roots.Count, Is.EqualTo(9), "NameAllA … SubPossibilities produce nodes"); + Assert.That(model.Diagnostics.Count(d => d.Code == "unknown-part-content"), Is.EqualTo(0), + "the content is imported, no longer dropped"); + var choice = model.Roots.Single(r => r.Kind == ViewNodeKind.ChoiceGroup); + Assert.That(choice.Children.Count, Is.EqualTo(2), "a where branch and an otherwise branch"); + Assert.That(choice.Children[0].Condition.ToString(), + Is.EqualTo("target=owner is=CmPossibilityList field=Depth intlessthan=2")); + Assert.That(choice.Children[1].Condition, Is.Null, "the otherwise branch has no condition"); + } + + /// + /// B10 fixture: CmBaseAnnotation 'Edit' resolves 'TextOnly' from CmAnnotation (one hop) and + /// 'BeginObjectLink' on the class itself. + /// + [Test] + public void CmBaseAnnotationEditLayout_ResolvesTextOnly_ViaCmAnnotation() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "CmBaseAnnotation", "Edit"); + + Assert.That(model.Diagnostics.Where(d => d.Code == "unresolved-part"), Is.Empty); + Assert.That(model.Roots.Count, Is.EqualTo(2)); + } + + /// + /// B10 fixture: the first-slice lane's hand-maintained MoForm map (4.10) is now subsumed by the + /// metadata-driven hierarchy — MoStemAllomorph's 'AsLexemeFormBasic' resolves with no hand map. + /// + [Test] + public void MoStemAllomorphAsLexemeFormBasic_ResolvesViaMetadataMap_WithoutHandMaintainedMap() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "MoStemAllomorph", "AsLexemeFormBasic"); + + Assert.That(model.Diagnostics.Where(d => d.Code == "unresolved-part"), Is.Empty); + Assert.That(model.Roots, Is.Not.Empty); + } + + /// + /// B10 fixture: a documented member of the remaining set. CmAnthroItem 'nested' refs 'Summary' + /// and no Summary detail part exists on CmAnthroItem/CmPossibility/CmObject — legacy DataTree + /// omits the slice the same way (DataTree.cs:2455-2457). + /// + [Test] + public void CmAnthroItemNestedLayout_SummaryStaysUnresolved_MatchingLegacyOmission() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "CmAnthroItem", "nested"); + + var unresolved = model.Diagnostics.Where(d => d.Code == "unresolved-part").ToList(); + Assert.That(unresolved.Count, Is.EqualTo(1)); + Assert.That(unresolved[0].Message, Does.Contain("'Summary'")); + } + + /// + /// B3 fixture: the real shipped variant/complex-form divergence. LexEntryRef/Normal's + /// VariantEntryTypes and ComplexEntryTypes parts are <if field="RefType" intequals=…> + /// twins — they must import as Conditional nodes with the structured condition, so the + /// composer can show exactly one per record. + /// + [Test] + public void ShippedLexEntryRefNormal_ImportsTheRefTypeConditionals() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "LexEntryRef", "Normal"); + + var conditionals = model.Roots.Where(r => r.Kind == ViewNodeKind.Conditional).ToList(); + Assert.That(conditionals.Count, Is.GreaterThanOrEqualTo(2), + "VariantEntryTypes and ComplexEntryTypes import as conditionals"); + var variant = conditionals.Single(c => c.Children.Any(ch => ch.Field == "VariantEntryTypes")); + var complex = conditionals.Single(c => c.Children.Any(ch => ch.Field == "ComplexEntryTypes")); + Assert.That(variant.Condition.Field, Is.EqualTo("RefType")); + Assert.That(variant.Condition.IntEquals, Is.EqualTo(0), "RefType 0 = variant"); + Assert.That(complex.Condition.IntEquals, Is.EqualTo(1), "RefType 1 = complex form"); + Assert.That(model.Diagnostics.Where(d => d.Code == "conditional-dropped"), Is.Empty, + "every condition form LexEntryRef/Normal uses is in the supported set"); + } + + /// + /// B2 fixture: the shipped lexeme-form ghost configuration must arrive complete on the typed + /// node — ghost/ghostWs/ghostLabel, the explicit ghostClass (MoStemAllomorph, differing from + /// the abstract MoForm field signature) AND the ghostInitMethod hook (SetMorphTypeToRoot) the + /// 14.1 lane used to drop. + /// + [Test] + public void ShippedLexEntryNormal_LexemeFormNode_CarriesTheFullGhostConfiguration() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + var model = ImportShippedLayout(repoRoot, layouts, parts, "LexEntry", "Normal"); + + var lexemeForm = model.Roots.Single(r => r.Field == "LexemeForm"); + Assert.That(lexemeForm.GhostField, Is.EqualTo("Form")); + Assert.That(lexemeForm.GhostWs, Is.EqualTo("vernacular")); + Assert.That(lexemeForm.GhostClass, Is.EqualTo("MoStemAllomorph")); + Assert.That(lexemeForm.GhostLabel, Is.EqualTo("Lexeme Form")); + Assert.That(lexemeForm.GhostInitMethod, Is.EqualTo("SetMorphTypeToRoot")); + } + + /// + /// B2 audit: every distinct ghost configuration in the shipped LEXICON detail parts imports + /// with its complete metadata (no ghost attribute is dropped from obj/seq nodes anymore). + /// The shipped `ghostAbbe` attribute is a typo legacy also ignores (it reads `ghostAbbr`, + /// DataTree.cs:2827), so it intentionally stays an unhandled-attribute diagnostic. + /// + [Test] + public void ShippedLexiconGhostConfigurations_AllImportWithCompleteMetadata() + { + var repoRoot = FindRepoRoot(); + var (layouts, parts) = LoadShippedFiles(repoRoot); + + // LexExampleSentence/Normal reaches TranslationsAllA: seq Translations ghost=Translation + // ghostWs=analysis ghostInitMethod=SetTypeToFreeTrans (implicit class CmTranslation). + var example = ImportShippedLayout(repoRoot, layouts, parts, "LexExampleSentence", "Normal"); + var translations = FindNode(example.Roots, n => n.Field == "Translations"); + Assert.That(translations, Is.Not.Null); + Assert.That(translations.GhostField, Is.EqualTo("Translation")); + Assert.That(translations.GhostWs, Is.EqualTo("analysis")); + Assert.That(translations.GhostClass, Is.Null, "class comes from the field signature"); + Assert.That(translations.GhostInitMethod, Is.EqualTo("SetTypeToFreeTrans")); + + // LexSense/Normal reaches Examples (ghost=Example ghostWs=vernacular) and ExtendedNotes + // (ghost=Discussion ghostWs=analysis), both without ghostClass/ghostInitMethod. + var sense = ImportShippedLayout(repoRoot, layouts, parts, "LexSense", "Normal"); + var examples = FindNode(sense.Roots, n => n.Field == "Examples" && n.GhostField != null); + Assert.That(examples?.GhostField, Is.EqualTo("Example")); + Assert.That(examples?.GhostWs, Is.EqualTo("vernacular")); + var notes = FindNode(sense.Roots, n => n.Field == "ExtendedNote"); + Assert.That(notes?.GhostField, Is.EqualTo("Discussion")); + Assert.That(notes?.GhostWs, Is.EqualTo("analysis")); + } + + private static ViewNode FindNode(IEnumerable nodes, Func predicate) + { + foreach (var node in nodes) + { + if (predicate(node)) + return node; + var inChildren = FindNode(node.Children, predicate); + if (inChildren != null) + return inChildren; + } + + return null; + } + + private static ViewDefinitionModel ImportShippedLayout(string repoRoot, + List layouts, List parts, string className, string layoutName) + { + var layout = LayoutSourceLoader.FindLayout(layouts.Select(f => f.Root), className, layoutName); + Assert.That(layout, Is.Not.Null, $"shipped layout {className}/{layoutName} not found"); + + var mergedParts = new XElement("PartInventory", parts.Select(f => f.Root)); + var resolver = new DictionaryPartResolver(mergedParts, LoadBaseClassMap(repoRoot)); + return new XmlLayoutImporter().Import(layout, resolver); + } + + private static void WriteReportArtifacts(string repoRoot, string markdown) + { + // Always write next to the test results. + var workCopy = Path.Combine(TestContext.CurrentContext.WorkDirectory, "layout-import-coverage.md"); + File.WriteAllText(workCopy, markdown); + TestContext.WriteLine($"coverage report: {workCopy}"); + + // Best effort: refresh the committed copy in the openspec change so the documented number + // can never drift from the measured one. Skipped silently on read-only checkouts. + try + { + var openspecCopy = Path.Combine(repoRoot, "openspec", "changes", + "lexical-edit-avalonia-migration", "layout-import-coverage.md"); + if (Directory.Exists(Path.GetDirectoryName(openspecCopy))) + { + File.WriteAllText(openspecCopy, markdown); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditFirstSliceTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditFirstSliceTests.cs new file mode 100644 index 0000000000..fb397a37ca --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/LexicalEditFirstSliceTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.IO; +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Task 4.10: the product first-slice definition is compiled from the live shipped layout inventory + /// (not hand-authored), with stable ids derived from the real layout paths. + /// + [TestFixture] + public class LexicalEditFirstSliceTests + { + private static string ShippedPartsDirectory() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + { + dir = dir.Parent; + } + + Assert.That(dir, Is.Not.Null, "could not locate the repo root from the test directory"); + return Path.Combine(dir.FullName, "DistFiles", "Language Explorer", "Configuration", "Parts"); + } + + [Test] + public void CompileFromLayoutDirectory_OverShippedLayouts_YieldsTheThreeFirstSliceFields() + { + var definition = LexicalEditFirstSlice.CompileFromLayoutDirectory(ShippedPartsDirectory()); + + Assert.That(definition, Is.Not.Null, "the shipped layouts must compile (fallback means a regression)"); + Assert.That(definition.Roots.Select(r => r.Field), Is.EqualTo(new[] { "Form", "MorphType", "Gloss" })); + Assert.That(definition.Diagnostics, Is.Empty, "the compiled definition carries no authored-fallback diagnostic"); + } + + [Test] + public void CompiledFields_CarryRealLayoutBindings_AndProductMetadata() + { + var definition = LexicalEditFirstSlice.CompileFromLayoutDirectory(ShippedPartsDirectory()); + Assert.That(definition, Is.Not.Null); + + var form = definition.Roots[0]; + Assert.That(form.RawEditor, Is.EqualTo("multistring"), "from MoForm-Detail-AsLexemeForm"); + Assert.That(form.WritingSystem, Is.EqualTo("all vernacular")); + Assert.That(form.Label, Is.EqualTo("Lexeme Form")); + Assert.That(form.EditorClassification, Is.EqualTo(EditorClassification.Known)); + + var morphType = definition.Roots[1]; + Assert.That(morphType.RawEditor, Is.EqualTo("MorphTypeAtomicReference"), "from MoForm-Detail-MorphTypeBasic"); + Assert.That(morphType.EditorClassification, Is.EqualTo(EditorClassification.Known), + "editor classification must be case-insensitive like DataTree's editor.ToLower()"); + + var gloss = definition.Roots[2]; + Assert.That(gloss.RawEditor, Is.EqualTo("multistring"), "from LexSense-Detail-GlossAllA"); + Assert.That(gloss.WritingSystem, Is.EqualTo("all analysis")); + + foreach (var node in definition.Roots) + { + Assert.That(node.Routing, Is.EqualTo(SurfaceRouting.Product)); + Assert.That(node.AutomationId, Is.Not.Null.And.Not.Empty); + Assert.That(node.StableId, Does.Not.StartWith("LexEntry/identity"), + "stable ids must derive from the real compiled layout paths, not the authored ones"); + } + } + + [Test] + public void CompiledDefinition_MapsToRegionFields_WithChooserAndTextKinds() + { + var definition = LexicalEditFirstSlice.CompileFromLayoutDirectory(ShippedPartsDirectory()); + Assert.That(definition, Is.Not.Null); + + var region = LexicalEditRegionMapper.FromViewDefinition(definition, new FakeRegionValueProvider()); + + Assert.That(region.Fields, Has.Count.EqualTo(3)); + Assert.That(region.Fields[0].Kind, Is.EqualTo(RegionFieldKind.Text)); + Assert.That(region.Fields[1].Kind, Is.EqualTo(RegionFieldKind.Chooser)); + Assert.That(region.Fields[2].Kind, Is.EqualTo(RegionFieldKind.Text)); + } + + [Test] + public void MissingDirectory_ReturnsNull_AndAuthoredFallbackCarriesDiagnostic() + { + Assert.That(LexicalEditFirstSlice.CompileFromLayoutDirectory(null), Is.Null); + Assert.That(LexicalEditFirstSlice.CompileFromLayoutDirectory( + Path.Combine(Path.GetTempPath(), "no-such-layout-dir")), Is.Null); + + var fallback = LexicalEditFirstSlice.AuthoredFallback(); + Assert.That(fallback.Roots.Select(r => r.Field), Is.EqualTo(new[] { "Form", "MorphType", "Gloss" })); + Assert.That(fallback.Diagnostics.Single().Code, Is.EqualTo("authored-fallback"), + "falling back must be visible, not silent"); + } + + [Test] + public void BaseClassFallback_ResolvesSubclassPartRefs() + { + var parts = new DictionaryPartResolver( + XElement.Parse(@" + + +"), + new System.Collections.Generic.Dictionary { { "MoStemAllomorph", "MoForm" } }); + + Assert.That(parts.ResolvePart("MoStemAllomorph", "detail", "AsLexemeForm"), Is.Not.Null, + "unresolved subclass refs must retry on the base class chain"); + Assert.That(parts.ResolvePart("MoStemAllomorph", "detail", "Nope"), Is.Null); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Path3BundleTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Path3BundleTests.cs new file mode 100644 index 0000000000..8656560d59 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Path3BundleTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Collections.Generic; +using System.IO; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Threading; +using Newtonsoft.Json; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// + /// Task 7.8 — produces the canonical Path 3 parity bundle for the first-slice scenario per the + /// 2.9 contract: shared scenarioId/bundleId/failureSummaryId plus a lane manifest that records + /// each lane as proven or pending (never silently omitted). Lanes assembled here: semantic + /// snapshot (typed IR), Avalonia visual (Skia rendered frame), WinForms visual (the committed + /// verified baseline), workflow/accessibility (UIA suites), performance (timing baselines). + /// + [TestFixture] + public class Path3BundleTests + { + [AvaloniaTest] + public void FirstSlice_Path3Bundle_AssemblesAllLanes_WithExplicitStatus() + { + var repoRoot = FindRepoRoot(); + var partsDir = Path.Combine(repoRoot, "DistFiles", "Language Explorer", "Configuration", "Parts"); + var definition = LexicalEditFirstSlice.CompileFromLayoutDirectory(partsDir); + Assert.That(definition, Is.Not.Null); + + var bundleDir = Path.Combine(TestContext.CurrentContext.WorkDirectory, "path3-first-slice"); + Directory.CreateDirectory(bundleDir); + const string scenarioId = "first-slice"; + var bundleId = scenarioId + "-bundle"; + + // Semantic lane: the deterministic typed-IR snapshot. + var semanticPath = Path.Combine(bundleDir, "semantic-snapshot.txt"); + File.WriteAllText(semanticPath, definition.ToSnapshot()); + + // Avalonia visual lane: a real Skia rendered frame of the region view. + var model = LexicalEditRegionMapper.FromViewDefinition(definition, new FakeRegionValueProvider()); + var window = new Window { Content = new LexicalEditRegionView(model), Width = 420, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var avaloniaPng = Path.Combine(bundleDir, "avalonia-visual.png"); + using (var frame = window.CaptureRenderedFrame()) + frame.Save(avaloniaPng); + + // WinForms visual lane: the committed legacy baseline for the production-like scenario. + var winFormsBaseline = Path.Combine(repoRoot, "Src", "Common", "Controls", "DetailControls", + "DetailControlsTests", + "DataTreeRenderTests.DataTreeRender_subsubsub-hidden-productionlike.verified.png"); + + var manifest = new + { + scenarioId, + bundleId, + failureSummaryId = bundleId + "-failures", + lanes = new Dictionary + { + ["semantic"] = new { status = "proven", artifact = "semantic-snapshot.txt" }, + ["visual-avalonia"] = new { status = "proven", artifact = "avalonia-visual.png" }, + ["visual-winforms"] = new + { + status = File.Exists(winFormsBaseline) ? "proven" : "pending", + artifact = winFormsBaseline + }, + ["workflow-accessibility"] = new + { + status = "proven", + artifact = "PreviewHostUiaTests (UIA names/order parity) + WinFormsUiaSmokeTests" + }, + ["performance"] = new + { + status = "proven", + artifact = "DataTreeTimingBaselines.json + region-manifest.md §5" + } + } + }; + var manifestPath = Path.Combine(bundleDir, "bundle.json"); + File.WriteAllText(manifestPath, JsonConvert.SerializeObject(manifest, Formatting.Indented)); + + Assert.That(new FileInfo(semanticPath).Length, Is.GreaterThan(0)); + Assert.That(new FileInfo(avaloniaPng).Length, Is.GreaterThan(0)); + Assert.That(File.Exists(winFormsBaseline), Is.True, "the committed legacy visual baseline is part of the bundle"); + TestContext.WriteLine("path3 bundle: " + manifestPath); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null); + return dir.FullName; + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs new file mode 100644 index 0000000000..ace7fecec5 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs @@ -0,0 +1,363 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests +{ + /// Records edit-context traffic so view editing behavior can be asserted without LCModel. + internal sealed class FakeRegionEditContext : IRegionEditContext + { + public readonly List<(string Field, string Ws, string Value)> TextEdits = new List<(string, string, string)>(); + public readonly List<(string Field, string Key)> OptionEdits = new List<(string, string)>(); + public IReadOnlyList ValidateResult = new List(); + public int CommitCount; + public int CancelCount; + + public bool IsOpen => TextEdits.Count + OptionEdits.Count > 0 && CommitCount == 0 && CancelCount == 0; + + public bool TrySetText(LexicalEditRegionField field, string ws, string value) + { + TextEdits.Add((field.Field, ws, value)); + return true; + } + + public bool TrySetOption(LexicalEditRegionField field, string optionKey) + { + OptionEdits.Add((field.Field, optionKey)); + return true; + } + + public IReadOnlyList Validate() => ValidateResult; + + public void Commit() => CommitCount++; + + public void Cancel() => CancelCount++; + } + + /// + /// Tasks 6.8/6.10/6.6: the region view drives editing through the edit-context seam — staging on + /// text/option change, validation-gated Save, Cancel rollback — with stable automation ids. + /// + [TestFixture] + public class RegionEditingViewTests + { + private static ViewDefinitionModel SampleDefinition() => new ViewDefinitionModel( + "LexEntry", "identity", "detail", + new List + { + new ViewNode("LexEntry/identity/#0", ViewNodeKind.Field, "Lexeme Form", null, "Form", "multistring", + EditorClassification.Known, "vernacular", ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "LexemeFormEditor", routing: SurfaceRouting.Product), + new ViewNode("LexEntry/identity/#1", ViewNodeKind.Field, "Morph Type", null, "MorphType", "morphtypeatomicreference", + EditorClassification.Known, null, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, + automationId: "MorphTypeChooser", routing: SurfaceRouting.Product) + }, + new List()); + + private sealed class EditingValueProvider : IRegionValueProvider + { + public IReadOnlyList GetValues(ViewNode fieldNode) + => fieldNode.Field == "Form" + ? new List { new RegionWsValue("vern", "casa") } + : (IReadOnlyList)new List(); + + public IReadOnlyList GetOptions(ViewNode fieldNode) + => new List { new RegionChoiceOption("g1", "stem"), new RegionChoiceOption("g2", "suffix") }; + + public string GetSelectedOptionKey(ViewNode fieldNode) => "g1"; + } + + private static (LexicalEditRegionView view, FakeRegionEditContext context, Window window) ShowEditable() + { + var model = LexicalEditRegionMapper.FromViewDefinition(SampleDefinition(), new EditingValueProvider()); + var context = new FakeRegionEditContext(); + var view = new LexicalEditRegionView(model, context); + var window = new Window { Content = view, Width = 500, Height = 260 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (view, context, window); + } + + private static T Find(LexicalEditRegionView view, string automationId) where T : Control + => view.GetVisualDescendants().OfType() + .FirstOrDefault(c => AutomationProperties.GetAutomationId(c) == automationId); + + [AvaloniaTest] + public void TextChange_StagesThroughTheEditContext() + { + var (view, context, _) = ShowEditable(); + var box = Find(view, "LexemeFormEditor.vern"); + Assert.That(box, Is.Not.Null); + Assert.That(box.IsReadOnly, Is.False, "an edit context makes the field writable"); + Assert.That(context.TextEdits, Is.Empty, "construction must not stage"); + + box.Text = "perro"; + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.TextEdits, Has.Count.EqualTo(1)); + Assert.That(context.TextEdits[0], Is.EqualTo(("Form", "vern", "perro"))); + } + + [AvaloniaTest] + public void ChooserChange_StagesOptionKeyThroughTheEditContext() + { + var (view, context, _) = ShowEditable(); + var chooser = Find(view, "MorphTypeChooser"); + Assert.That(chooser, Is.Not.Null, "the chooser renders as the owned flyout field"); + Assert.That(chooser.Content, Is.EqualTo("stem"), "shows the current selection"); + + var options = (ListBox)((Flyout)chooser.Flyout).Content; + options.SelectedItem = "suffix"; + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.OptionEdits, Has.Count.EqualTo(1)); + Assert.That(context.OptionEdits[0], Is.EqualTo(("MorphType", "g2"))); + Assert.That(chooser.SelectedKey, Is.EqualTo("g2"), "the staged selection becomes current"); + Assert.That(chooser.Content, Is.EqualTo("suffix")); + } + + // 14.4 — autosave: the legacy view saves as you go, so a staged session commits the moment + // an editor loses focus; there are no Save/Cancel buttons. + [AvaloniaTest] + public void AutoSave_OnFocusLoss_WhenClean_CommitsOnce_AndRaisesEditCompleted() + { + var (view, context, _) = ShowEditable(); + var completed = 0; + view.EditCompleted += (s, e) => completed++; + var box = Find(view, "LexemeFormEditor.vern"); + + box.RaiseEvent(new RoutedEventArgs(InputElement.LostFocusEvent)); + Dispatcher.UIThread.RunJobs(); + Assert.That(context.CommitCount, Is.EqualTo(0), "no open session, nothing to autosave"); + + box.Text = "perro"; // stage: opens the session + Dispatcher.UIThread.RunJobs(); + box.RaiseEvent(new RoutedEventArgs(InputElement.LostFocusEvent)); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.CommitCount, Is.EqualTo(1), "focus loss commits the open session"); + Assert.That(context.CancelCount, Is.EqualTo(0)); + Assert.That(completed, Is.EqualTo(1)); + } + + [AvaloniaTest] + public void AutoSave_WithValidationErrors_ShowsThemInline_AndDoesNotCommit() + { + var (view, context, _) = ShowEditable(); + context.ValidateResult = new List { "A Lexeme Form is required." }; + var box = Find(view, "LexemeFormEditor.vern"); + + box.Text = ""; + Dispatcher.UIThread.RunJobs(); + box.RaiseEvent(new RoutedEventArgs(InputElement.LostFocusEvent)); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.CommitCount, Is.EqualTo(0), "validation errors must block the autosave"); + var errors = Find(view, "RegionEditor.ValidationErrors"); + Assert.That(errors.IsVisible, Is.True, "a blocked autosave is never silent"); + Assert.That(errors.Text, Does.Contain("required")); + } + + [AvaloniaTest] + public void Escape_CancelsTheSession_AndRaisesEditCompleted() + { + var (view, context, _) = ShowEditable(); + var completed = 0; + view.EditCompleted += (s, e) => completed++; + + view.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = Key.Escape, + Source = view + }); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.CancelCount, Is.EqualTo(1)); + Assert.That(context.CommitCount, Is.EqualTo(0)); + Assert.That(completed, Is.EqualTo(1)); + } + + [AvaloniaTest] + public void EditMode_HasNoSaveCancelButtons_BecauseItAutoSaves() + { + var (view, _, _) = ShowEditable(); + Assert.That(Find public sealed class ViewDefinitionSourceSnapshot { - public ViewDefinitionSourceSnapshot(string className, string layoutType, string layoutXml, string partsXml) + public ViewDefinitionSourceSnapshot(string className, string layoutType, string layoutXml, string partsXml, + IReadOnlyDictionary baseClassMap = null) { ClassName = className ?? ""; LayoutType = string.IsNullOrEmpty(layoutType) ? "detail" : layoutType; LayoutXml = layoutXml ?? ""; PartsXml = partsXml ?? ""; + BaseClassMap = baseClassMap; } public string ClassName { get; } @@ -37,6 +40,9 @@ public ViewDefinitionSourceSnapshot(string className, string layoutType, string /// The <PartInventory> (or <bin>) source. public string PartsXml { get; } + /// Optional subclass → base class chain used for part-ref resolution fallback. + public IReadOnlyDictionary BaseClassMap { get; } + /// The layout name parsed from . public string LayoutName => (string)XElement.Parse(LayoutXml).Attribute("name") ?? ""; @@ -45,7 +51,12 @@ public string ComputeFingerprint() { using (var sha = SHA256.Create()) { - var bytes = Encoding.UTF8.GetBytes(ClassName + "\n" + LayoutType + "\n" + LayoutXml + "\n" + PartsXml); + var baseMapText = BaseClassMap == null + ? "" + : string.Join(";", BaseClassMap.OrderBy(p => p.Key, StringComparer.Ordinal) + .Select(p => p.Key + ">" + p.Value)); + var bytes = Encoding.UTF8.GetBytes( + ClassName + "\n" + LayoutType + "\n" + LayoutXml + "\n" + PartsXml + "\n" + baseMapText); var hash = sha.ComputeHash(bytes); var sb = new StringBuilder(hash.Length * 2); foreach (var b in hash) @@ -192,7 +203,7 @@ private ViewDefinitionModel CompileCore(ViewDefinitionSourceSnapshot snapshot, C { cancellationToken.ThrowIfCancellationRequested(); var layout = XElement.Parse(snapshot.LayoutXml); - var parts = new DictionaryPartResolver(XElement.Parse(snapshot.PartsXml)); + var parts = new DictionaryPartResolver(XElement.Parse(snapshot.PartsXml), snapshot.BaseClassMap); cancellationToken.ThrowIfCancellationRequested(); return _importer.Import(layout, parts); } diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs new file mode 100644 index 0000000000..949d710b89 --- /dev/null +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -0,0 +1,201 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition +{ + /// + /// Canonical JSON serialization of the typed view definition (tasks 9.2/9.4, per + /// `canonical-view-definition-design.md`): deterministic property order, defaults omitted, and a + /// `formatVersion` header so per-project overrides can be validated. This is the migration + /// tooling core — shipped XML compiles to the typed IR (existing importer), the IR serializes to + /// canonical JSON, and a gated surface can load JSON with the XML importer retained as fallback. + /// + public static class ViewDefinitionJsonSerializer + { + /// Successor version line to the legacy XML `LayoutVersionNumber`. + public const int FormatVersion = 1; + + public static string Serialize(ViewDefinitionModel model) + { + if (model == null) throw new ArgumentNullException(nameof(model)); + + var root = new JObject + { + ["formatVersion"] = FormatVersion, + ["class"] = model.ClassName, + ["name"] = model.LayoutName, + ["type"] = model.LayoutType, + ["nodes"] = new JArray(model.Roots.Select(WriteNode)) + }; + return root.ToString(Formatting.Indented); + } + + public static ViewDefinitionModel Deserialize(string json) + { + if (string.IsNullOrEmpty(json)) throw new ArgumentNullException(nameof(json)); + var root = JObject.Parse(json); + + var version = (int?)root["formatVersion"] ?? -1; + if (version != FormatVersion) + throw new InvalidDataException($"Unsupported view-definition formatVersion {version} (expected {FormatVersion})."); + + var nodes = ((JArray)root["nodes"] ?? new JArray()).Select(ReadNode).ToList(); + return new ViewDefinitionModel( + (string)root["class"] ?? "", + (string)root["name"] ?? "", + (string)root["type"] ?? "detail", + nodes, + Array.Empty()); + } + + private static JObject WriteNode(ViewNode node) + { + // Deterministic order; defaults omitted so committed definitions diff cleanly. + var o = new JObject + { + ["id"] = node.StableId, + ["kind"] = node.Kind.ToString() + }; + AddIfPresent(o, "label", node.Label); + AddIfPresent(o, "abbr", node.Abbreviation); + AddIfPresent(o, "field", node.Field); + AddIfPresent(o, "editor", node.RawEditor); + if (node.EditorClassification != EditorClassification.GroupingNone) + o["editorClass"] = node.EditorClassification.ToString(); + AddIfPresent(o, "ws", node.WritingSystem); + if (node.Visibility != ViewVisibility.Always) + o["visibility"] = node.Visibility.ToString(); + if (node.Expansion != ViewExpansion.NotApplicable) + o["expansion"] = node.Expansion.ToString(); + if (node.Indented) + o["indented"] = true; + AddIfPresent(o, "targetLayout", node.TargetLayout); + AddIfPresent(o, "localizationKey", node.LocalizationKey); + AddIfPresent(o, "automationId", node.AutomationId); + if (node.Routing != SurfaceRouting.Inherit) + o["routing"] = node.Routing.ToString(); + if (node.BoldEmphasis) + o["bold"] = true; + if (node.FontScalePercent != 0) + o["fontScalePercent"] = node.FontScalePercent; + AddIfPresent(o, "menu", node.MenuId); + AddIfPresent(o, "contextMenu", node.ContextMenuId); + AddIfPresent(o, "hotlinks", node.HotlinksId); + AddIfPresent(o, "ghost", node.GhostField); + AddIfPresent(o, "ghostWs", node.GhostWs); + AddIfPresent(o, "ghostClass", node.GhostClass); + AddIfPresent(o, "ghostLabel", node.GhostLabel); + AddIfPresent(o, "ghostInitMethod", node.GhostInitMethod); + if (node.Condition != null) + o["condition"] = WriteCondition(node.Condition); + if (node.Children.Count > 0) + o["children"] = new JArray(node.Children.Select(WriteNode)); + return o; + } + + // B3: the structured conditional-display metadata (legacy //), reserved in + // the canonical schema before Layer-1 freezes (xml-retirement-blockers, cross-cutting deadline). + private static JObject WriteCondition(ViewCondition condition) + { + var o = new JObject(); + if (condition.Negated) + o["negated"] = true; + AddIfPresent(o, "target", condition.Target); + AddIfPresent(o, "is", condition.IsClass); + if (condition.ExcludeSubclasses) + o["excludeSubclasses"] = true; + AddIfPresent(o, "field", condition.Field); + if (condition.BoolEquals.HasValue) + o["boolEquals"] = condition.BoolEquals.Value; + if (condition.IntEquals.HasValue) + o["intEquals"] = condition.IntEquals.Value; + if (condition.IntLessThan.HasValue) + o["intLessThan"] = condition.IntLessThan.Value; + if (condition.IntGreaterThan.HasValue) + o["intGreaterThan"] = condition.IntGreaterThan.Value; + AddIfPresent(o, "intMemberOf", condition.IntMemberOf); + if (condition.LengthAtLeast.HasValue) + o["lengthAtLeast"] = condition.LengthAtLeast.Value; + if (condition.LengthAtMost.HasValue) + o["lengthAtMost"] = condition.LengthAtMost.Value; + AddIfPresent(o, "guidEquals", condition.GuidEquals); + return o; + } + + private static ViewCondition ReadCondition(JObject o) + { + if (o == null) + return null; + return new ViewCondition( + (bool?)o["negated"] ?? false, + (string)o["target"], + (string)o["is"], + (bool?)o["excludeSubclasses"] ?? false, + (string)o["field"], + (bool?)o["boolEquals"], + (int?)o["intEquals"], + (int?)o["intLessThan"], + (int?)o["intGreaterThan"], + (string)o["intMemberOf"], + (int?)o["lengthAtLeast"], + (int?)o["lengthAtMost"], + (string)o["guidEquals"]); + } + + private static ViewNode ReadNode(JToken token) + { + var o = (JObject)token; + var children = ((JArray)o["children"])?.Select(ReadNode).ToList() + ?? (IReadOnlyList)Array.Empty(); + + return new ViewNode( + (string)o["id"], + ParseEnum(o, "kind", ViewNodeKind.Field), + (string)o["label"], + (string)o["abbr"], + (string)o["field"], + (string)o["editor"], + ParseEnum(o, "editorClass", EditorClassification.GroupingNone), + (string)o["ws"], + ParseEnum(o, "visibility", ViewVisibility.Always), + ParseEnum(o, "expansion", ViewExpansion.NotApplicable), + (bool?)o["indented"] ?? false, + (string)o["targetLayout"], + children, + (string)o["localizationKey"], + (string)o["automationId"], + ParseEnum(o, "routing", SurfaceRouting.Inherit), + (bool?)o["bold"] ?? false, + (int?)o["fontScalePercent"] ?? 0, + (string)o["menu"], + (string)o["contextMenu"], + (string)o["hotlinks"], + (string)o["ghost"], + (string)o["ghostWs"], + (string)o["ghostClass"], + (string)o["ghostLabel"], + ghostInitMethod: (string)o["ghostInitMethod"], + condition: ReadCondition((JObject)o["condition"])); + } + + private static T ParseEnum(JObject o, string name, T fallback) where T : struct + { + var value = (string)o[name]; + return value != null && Enum.TryParse(value, out var parsed) ? parsed : fallback; + } + + private static void AddIfPresent(JObject o, string name, string value) + { + if (!string.IsNullOrEmpty(value)) + o[name] = value; + } + } +} diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 391ca66f36..86e0c06319 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -31,7 +31,22 @@ public enum ViewNodeKind Sequence, /// The custom-field placeholder expanded from the model (legacy customFields="here"). - CustomFieldPlaceholder + CustomFieldPlaceholder, + + /// + /// A conditional-display wrapper (legacy <if>/<ifnot>, B3): its + /// is evaluated per object at compose time and the children + /// render only when it passes. Inside a a null condition is the + /// legacy <otherwise> branch. + /// + Conditional, + + /// + /// A legacy <choice> (B3): children are branches + /// (<where>/<otherwise>); only the FIRST branch whose condition + /// passes renders, matching DataTree.ProcessSubpartNode's choice handling. + /// + ChoiceGroup } /// Field visibility, mirroring the legacy visibility attribute. @@ -142,6 +157,130 @@ public override string ToString() => $"{Severity}: [{Code}] {Message} ({NodePath})"; } + /// + /// Structured conditional-display metadata imported from legacy <if>/<ifnot>/ + /// <where> elements (B3, xml-retirement-blockers). Attribute semantics mirror + /// XmlVc.ConditionPasses exactly as DataTree.ProcessSubpartNode invokes it: every test + /// present must pass (conjunction); <ifnot> sets . Only the condition + /// vocabulary the shipped DETAIL layouts actually use is represented — target, is, + /// excludesubclasses, field, boolequals, intequals, intlessthan, + /// intgreaterthan, intmemberof, lengthatleast, lengthatmost, + /// guidequals. The publishing-lane-only forms (stringequals, stringaltequals, + /// hvoequals, flidequals, bidi, atleastoneis, func, slash field + /// paths and $-substituted values) keep the importer's conditional-dropped lane. + /// + public sealed class ViewCondition + { + public ViewCondition( + bool negated = false, + string target = null, + string isClass = null, + bool excludeSubclasses = false, + string field = null, + bool? boolEquals = null, + int? intEquals = null, + int? intLessThan = null, + int? intGreaterThan = null, + string intMemberOf = null, + int? lengthAtLeast = null, + int? lengthAtMost = null, + string guidEquals = null) + { + Negated = negated; + Target = target; + IsClass = isClass; + ExcludeSubclasses = excludeSubclasses; + Field = field; + BoolEquals = boolEquals; + IntEquals = intEquals; + IntLessThan = intLessThan; + IntGreaterThan = intGreaterThan; + IntMemberOf = intMemberOf; + LengthAtLeast = lengthAtLeast; + LengthAtMost = lengthAtMost; + GuidEquals = guidEquals; + } + + /// True for <ifnot>: the content shows when the condition FAILS. + public bool Negated { get; } + + /// Legacy target=: which object the tests read — null/"this" (default), "owner", or an atomic field name (XmlVc.GetActualTarget). + public string Target { get; } + + /// Legacy is=: the object must be this class (or a subclass unless ). + public string IsClass { get; } + + /// Legacy excludesubclasses= for . + public bool ExcludeSubclasses { get; } + + /// Legacy field=: the property the value/length tests read on the target object. + public string Field { get; } + + /// Legacy boolequals= (a missing object/field reads as false, like GetBoolValueFromCache). + public bool? BoolEquals { get; } + + /// Legacy intequals=. + public int? IntEquals { get; } + + /// Legacy intlessthan=. + public int? IntLessThan { get; } + + /// Legacy intgreaterthan=. + public int? IntGreaterThan { get; } + + /// Legacy intmemberof=: comma-separated integers, preserved verbatim. + public string IntMemberOf { get; } + + /// Legacy lengthatleast= (vector size; atomic counts 0/1). + public int? LengthAtLeast { get; } + + /// Legacy lengthatmost=. + public int? LengthAtMost { get; } + + /// Legacy guidequals=: the atomic reference in must point at the object with this guid. + public string GuidEquals { get; } + + /// Deterministic summary used by . + public override string ToString() + { + var sb = new StringBuilder(); + void Append(string name, string value) + { + if (string.IsNullOrEmpty(value)) + return; + if (sb.Length > 0) + sb.Append(' '); + sb.Append(name).Append('=').Append(value); + } + + if (Negated) + { + sb.Append("not"); + } + + Append("target", Target); + Append("is", IsClass); + if (ExcludeSubclasses) + Append("excludesubclasses", "true"); + Append("field", Field); + if (BoolEquals.HasValue) + Append("boolequals", BoolEquals.Value ? "true" : "false"); + if (IntEquals.HasValue) + Append("intequals", IntEquals.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (IntLessThan.HasValue) + Append("intlessthan", IntLessThan.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (IntGreaterThan.HasValue) + Append("intgreaterthan", IntGreaterThan.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Append("intmemberof", IntMemberOf); + if (LengthAtLeast.HasValue) + Append("lengthatleast", LengthAtLeast.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (LengthAtMost.HasValue) + Append("lengthatmost", LengthAtMost.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Append("guidequals", GuidEquals); + return sb.ToString(); + } + } + /// /// An immutable typed view-definition node. This is the framework-neutral migration contract that /// both the legacy WinForms adapter and the future Avalonia adapter consume instead of raw XML. @@ -165,7 +304,20 @@ public ViewNode( IReadOnlyList children, string localizationKey = null, string automationId = null, - SurfaceRouting routing = SurfaceRouting.Inherit) + SurfaceRouting routing = SurfaceRouting.Inherit, + bool boldEmphasis = false, + int fontScalePercent = 0, + string menuId = null, + string contextMenuId = null, + string hotlinksId = null, + string ghostField = null, + string ghostWs = null, + string ghostClass = null, + string ghostLabel = null, + string customEditorClass = null, + string customEditorAssembly = null, + string ghostInitMethod = null, + ViewCondition condition = null) { StableId = stableId; Kind = kind; @@ -183,6 +335,19 @@ public ViewNode( LocalizationKey = localizationKey; AutomationId = automationId; Routing = routing; + BoldEmphasis = boldEmphasis; + FontScalePercent = fontScalePercent; + MenuId = menuId; + ContextMenuId = contextMenuId; + HotlinksId = hotlinksId; + GhostField = ghostField; + GhostWs = ghostWs; + GhostClass = ghostClass; + GhostLabel = ghostLabel; + CustomEditorClass = customEditorClass; + CustomEditorAssembly = customEditorAssembly; + GhostInitMethod = ghostInitMethod; + Condition = condition; } /// Deterministic identity derived from the node's path (stable across realizations). @@ -229,6 +394,59 @@ public ViewNode( /// Product-vs-preview routing for this node (task 4.7). Defaults to . public SurfaceRouting Routing { get; } + + /// Bold emphasis from the part's <properties><bold value='on'/> (e.g. the lexeme form). + public bool BoldEmphasis { get; } + + /// Font scale percent from <properties><fontsize value='120%'/>; 0 = unscaled. + public int FontScalePercent { get; } + + /// Legacy slice context menu id (layout `menu=`), e.g. mnuDataTree-Sense (13.1). + public string MenuId { get; } + + /// Legacy in-string context menu id (`contextMenu=`), e.g. mnuDataTree-LexemeFormContext. + public string ContextMenuId { get; } + + /// Legacy hotlinks menu id (`hotlinks=`), the section summary-line link commands. + public string HotlinksId { get; } + + /// Legacy ghost binding (`ghost=`): the field of the to-be-created object that receives the typed text. + public string GhostField { get; } + + /// Legacy `ghostWs=`: which default writing system the ghost text goes into (vernacular/analysis/pronunciation). + public string GhostWs { get; } + + /// Legacy `ghostClass=`: the concrete class to create when the model class is abstract. + public string GhostClass { get; } + + /// Legacy `ghostLabel=`: the row label shown while the object does not exist yet. + public string GhostLabel { get; } + + /// + /// For a legacy dynamically loaded slice (editor="Custom"), the fully qualified slice + /// class (`class=`). Carried so hosts can promote designated WinForms-only custom slices + /// (e.g. the Chorus Messages notes bar) to a hybrid companion lane instead of rendering an + /// unsupported row. Null for every other node. + /// + public string CustomEditorClass { get; } + + /// The dll the custom slice class loads from (`assemblyPath=`), e.g. LexEdDll.dll. + public string CustomEditorAssembly { get; } + + /// + /// Legacy `ghostInitMethod=`: a no-argument method invoked by reflection on the newly created + /// object after the ghost text lands (B2; GhostStringSliceView.MakeRealObject, + /// GhostStringSlice.cs:321-328), e.g. SetMorphTypeToRoot on a new lexeme-form allomorph + /// or SetTypeToFreeTrans on a new example translation. + /// + public string GhostInitMethod { get; } + + /// + /// Conditional-display metadata (B3): non-null on nodes + /// (except the <otherwise> branch of a , + /// which renders when no sibling condition passed). Evaluated per object at compose time. + /// + public ViewCondition Condition { get; } } /// @@ -309,6 +527,10 @@ private static string AppendMetadata(ViewNode node) sb.Append($" | autoId={node.AutomationId}"); if (node.Routing != SurfaceRouting.Inherit) sb.Append($" | routing={node.Routing}"); + // B3: conditional nodes are new (never in pre-existing baselines), so the condition summary + // rides the snapshot — JSON round-trip equality fails if condition metadata is dropped. + if (node.Condition != null) + sb.Append($" | cond=[{node.Condition}]"); return sb.ToString(); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index d3a124ebf7..8f31d1f695 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -16,6 +16,56 @@ namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition /// public sealed class XmlLayoutImporter : IViewDefinitionImporter { + // Task 4.9: the attribute vocabulary the importer actually consumes, per element role. Anything + // outside these sets is reported with an `unhandled-attribute` diagnostic instead of being + // silently dropped, so importer coverage is measurable (see LayoutImportCoverage). + public static readonly HashSet HandledLayoutAttributes = + new HashSet(System.StringComparer.Ordinal) { "class", "type", "name", "version" }; + + public static readonly HashSet HandledCallerPartAttributes = + new HashSet(System.StringComparer.Ordinal) + { + "ref", "label", "abbr", "visibility", "expansion", "param", "customFields", + "localizationKey", "labelId", "automationId", "surface", "menu", "hotlinks" + }; + + public static readonly HashSet HandledSliceAttributes = + new HashSet(System.StringComparer.Ordinal) + { + "label", "abbr", "field", "ws", "editor", "visibility", "expansion", + "localizationKey", "labelId", "automationId", "surface", "menu", "contextMenu", "hotlinks" + }; + + public static readonly HashSet HandledObjSeqAttributes = + new HashSet(System.StringComparer.Ordinal) + { + "field", "layout", "label", "abbr", "ws", "visibility", "expansion", + "localizationKey", "labelId", "automationId", "surface", "menu", "hotlinks", + "ghost", "ghostWs", "ghostClass", "ghostLabel", "ghostInitMethod" + }; + + // B3: the condition vocabulary the importer parses into ViewCondition — exactly the forms the + // shipped DETAIL layouts use (audited 2026-06-11 over DistFiles .../Parts: boolequals 44, + // intequals 9, lengthatleast/-most 8, intmemberof 2, intlessthan 5, guidequals 2, is/target on + // where clauses). Publishing-lane-only forms (stringequals, stringaltequals, hvoequals, + // flidequals, bidi, atleastoneis, func, index, ws, class, flid) are NOT parsed; a condition + // carrying one keeps the conditional-dropped lane so it is never evaluated wrongly. + public static readonly HashSet HandledConditionAttributes = + new HashSet(System.StringComparer.Ordinal) + { + "target", "is", "excludesubclasses", "field", "boolequals", "intequals", + "intlessthan", "intgreaterthan", "intmemberof", "lengthatleast", "lengthatmost", + "guidequals" + }; + + // Dropped attributes that change behavior (menus, ghost lines) get Warning severity; purely + // presentational ones (styles, separators, numbering) get Info. + private static readonly HashSet FunctionalDroppedAttributes = + new HashSet(System.StringComparer.Ordinal) + { + "ghostAbbr", "ghostField", "ghostInitMethod", "editor" + }; + /// public ViewDefinitionModel Import(XElement layoutElement, IPartResolver parts) { @@ -54,6 +104,34 @@ private void ProcessContainer( var indentFlag = indentAttr == null || indentAttr != "false"; ProcessContainer(el.Elements(), parts, className, layoutType, parentPath, indentFlag, output, diagnostics); break; + // Task 4.9: named drop codes for the real-layout constructs the importer does not + // expand yet, so coverage reports can count them instead of lumping them as unknown. + case "generate": + diagnostics.Add(new ViewDiagnostic( + ViewDiagnosticSeverity.Warning, + "generated-content-dropped", + $" drives schema/custom-field UI generation and is not imported.", + $"{parentPath}/#{output.Count}")); + break; + // B3: conditionals import as typed Conditional/ChoiceGroup nodes (evaluated per + // object at compose time); unsupported condition forms still drop with a diagnostic. + case "if": + case "ifnot": + case "choice": + { + var conditional = BuildNode(el, el, parts, className, layoutType, + $"{parentPath}/#{output.Count}", indented, diagnostics); + if (conditional != null) + output.Add(conditional); + break; + } + case "sublayout": + diagnostics.Add(new ViewDiagnostic( + ViewDiagnosticSeverity.Info, + "sublayout-dropped", + $" is a publishing construct and is not imported for detail views.", + $"{parentPath}/#{output.Count}")); + break; default: diagnostics.Add(new ViewDiagnostic( ViewDiagnosticSeverity.Warning, @@ -99,6 +177,39 @@ private void ProcessPart( { diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Error, "unresolved-part", $"Could not resolve part ref '{refName}' for class '{className}'.", stableId)); + + // Recover the caller's structural children so an unresolved *section* part (e.g. + // LexSense/Normal's HeavySummary, which has no shipped part definition) does not drop + // its real fields: . + // Legacy DataTree omits the whole subtree here; recovering the children is strictly + // more faithful to what users see and keeps the diagnostic for the audit trail. + var recoverable = new List(); + foreach (var child in callerEl.Elements()) + { + if (child.Name.LocalName == "indent" || child.Name.LocalName == "part") + recoverable.Add(child); + } + + if (recoverable.Count > 0) + { + // 15.3: the recovered children ride a group node that keeps the CALLER's bindings — + // HeavySummary's menu="mnuDataTree-Sense"/hotlinks survive here so the composed + // per-sense headers can offer the legacy sense menu (Insert Sense etc.). + var recoveredChildren = new List(); + ProcessContainer(recoverable, parts, className, layoutType, stableId, indented, + recoveredChildren, diagnostics); + if (recoveredChildren.Count > 0) + { + output.Add(new ViewNode(stableId, ViewNodeKind.Group, + Attr(callerEl, "label"), Attr(callerEl, "abbr"), null, null, + EditorClassification.GroupingNone, null, + ParseVisibility(Attr(callerEl, "visibility")), + ParseExpansion(Attr(callerEl, "expansion")), indented, null, + recoveredChildren, + menuId: Attr(callerEl, "menu"), + hotlinksId: Attr(callerEl, "hotlinks"))); + } + } return; } @@ -126,10 +237,25 @@ private ViewNode BuildNode( var field = Attr(contentEl, "field"); var ws = Attr(contentEl, "ws"); + // Task 4.9: report attributes the importer drops instead of dropping them silently. The caller + // element is reported only when distinct from the content element (inline children pass the + // same element for both). + if (!ReferenceEquals(callerEl, contentEl)) + { + ReportUnhandledAttributes(callerEl, HandledCallerPartAttributes, "part ref", stableId, diagnostics); + ReportSubstitutionValues(callerEl, HandledCallerPartAttributes, stableId, diagnostics); + } + // Task 4.7 metadata. Legacy XML Parts/Layout does not carry these, so they stay null/Inherit for // imported layouts (preserving semantic baselines); authored or region-spec sources may set them. var localizationKey = Attr(callerEl, "localizationKey") ?? Attr(contentEl, "localizationKey") ?? Attr(callerEl, "labelId") ?? Attr(contentEl, "labelId"); + + // 13.1: legacy menu bindings — slice menu from the caller (layout part) first, like + // DTMenuHandler.ShowContextMenu2Id; in-string contextMenu lives on the slice content. + var menuId = Attr(callerEl, "menu") ?? Attr(contentEl, "menu"); + var contextMenuId = Attr(contentEl, "contextMenu"); + var hotlinksId = Attr(callerEl, "hotlinks") ?? Attr(contentEl, "hotlinks"); var automationId = Attr(callerEl, "automationId") ?? Attr(contentEl, "automationId"); var routing = ParseRouting(Attr(callerEl, "surface") ?? Attr(contentEl, "surface")); @@ -139,7 +265,25 @@ private ViewNode BuildNode( { var editor = Attr(contentEl, "editor"); var classification = EditorKindMap.Classify(editor); + + // Viewing parity (11.15): capture the visual-emphasis legacy slices + // honor (bold + percentage fontsize, e.g. the lexeme form's bold/120%). + var boldEmphasis = false; + var fontScalePercent = 0; + var properties = contentEl.Element("properties"); + if (properties != null) + { + boldEmphasis = (string)properties.Element("bold")?.Attribute("value") == "on"; + var fontsize = (string)properties.Element("fontsize")?.Attribute("value"); + if (fontsize != null && fontsize.EndsWith("%", System.StringComparison.Ordinal) + && int.TryParse(fontsize.TrimEnd('%'), out var percent)) + { + fontScalePercent = percent; + } + } RaiseEditorDiagnostics(editor, classification, stableId, diagnostics); + ReportUnhandledAttributes(contentEl, HandledSliceAttributes, "slice", stableId, diagnostics); + ReportSubstitutionValues(contentEl, HandledSliceAttributes, stableId, diagnostics); var childElements = new List(); foreach (var child in contentEl.Elements()) @@ -148,32 +292,148 @@ private ViewNode BuildNode( { childElements.Add(child); } + else if (child.Name.LocalName != "properties") + { + // is consumed above (11.15 emphasis); the rest is reported. + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Info, "slice-content-dropped", + $"Slice content child <{child.Name.LocalName}> is not imported.", stableId)); + } + } + + var children = new List(); + BuildInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); + + // Caller children under a slice-content part (/ wrappers on a section + // part, e.g. AsLexemeForm's MorphTypeBasic) become child nodes, mirroring how + // DataTree.ProcessPartRefNode realizes them as indented child slices. Other caller + // child kinds are reported, not silently dropped (task 4.9). + if (!ReferenceEquals(callerEl, contentEl)) + { + var structuralCallerChildren = new List(); + foreach (var callerChild in callerEl.Elements()) + { + if (callerChild.Name.LocalName == "indent" || callerChild.Name.LocalName == "part") + { + structuralCallerChildren.Add(callerChild); + } + else + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "caller-children-dropped", + $"Caller child <{callerChild.Name.LocalName}> under part ref with slice content is not imported.", + stableId)); + } + } + + ProcessContainer(structuralCallerChildren, parts, className, layoutType, stableId, false, + children, diagnostics); } - if (classification == EditorClassification.GroupingNone && childElements.Count > 0) + if (classification == EditorClassification.GroupingNone && children.Count > 0) { - var children = new List(); - BuildInlineChildren(childElements, parts, className, layoutType, stableId, children, diagnostics); return new ViewNode(stableId, ViewNodeKind.Group, label, abbreviation, field, editor, classification, ws, visibility, expansion, indented, null, children, - localizationKey, automationId, routing); + localizationKey, automationId, routing, boldEmphasis, fontScalePercent, + menuId, contextMenuId, hotlinksId); } - return MakeLeaf(stableId, ViewNodeKind.Field, label, abbreviation, field, editor, - classification, ws, visibility, expansion, indented, null, - localizationKey, automationId, routing); + // Dynamic custom slices keep their legacy class/assembly identity so the host can + // promote designated WinForms-only editors (e.g. the Chorus Messages notes bar) to + // the hybrid companion lane instead of an unsupported row. The attributes stay in + // the unhandled-attribute report (no Avalonia editor consumes them). + return new ViewNode(stableId, ViewNodeKind.Field, label, abbreviation, field, editor, + classification, ws, visibility, expansion, indented, null, children, + localizationKey, automationId, routing, boldEmphasis, fontScalePercent, + menuId, contextMenuId, hotlinksId, + customEditorClass: Attr(contentEl, "class"), + customEditorAssembly: Attr(contentEl, "assemblyPath")); } case "obj": case "seq": { var kind = contentEl.Name.LocalName == "obj" ? ViewNodeKind.ObjectAtom : ViewNodeKind.Sequence; var targetLayout = Attr(callerEl, "param") ?? Attr(contentEl, "layout"); + ReportUnhandledAttributes(contentEl, HandledObjSeqAttributes, contentEl.Name.LocalName, stableId, diagnostics); + ReportSubstitutionValues(contentEl, HandledObjSeqAttributes, stableId, diagnostics); var children = new List(); BuildInjectedChildren(callerEl, parts, layoutType, stableId, children, diagnostics); + // 14.1: the legacy ghost bindings ride the typed node so empty fields can offer + // the create-on-edit add-prompt line (DataTree ghost slices). return new ViewNode(stableId, kind, label, abbreviation, field, null, EditorClassification.GroupingNone, ws, visibility, expansion, indented, targetLayout, children, - localizationKey, automationId, routing); + localizationKey, automationId, routing, menuId: menuId, contextMenuId: contextMenuId, + hotlinksId: hotlinksId, + ghostField: Attr(contentEl, "ghost") ?? Attr(callerEl, "ghost"), + ghostWs: Attr(contentEl, "ghostWs") ?? Attr(callerEl, "ghostWs"), + ghostClass: Attr(contentEl, "ghostClass") ?? Attr(callerEl, "ghostClass"), + ghostLabel: Attr(contentEl, "ghostLabel") ?? Attr(callerEl, "ghostLabel"), + // B2: the layout's post-create hook rides the node so the composer's ghost + // setter can invoke it the way GhostStringSliceView.MakeRealObject does. + ghostInitMethod: Attr(contentEl, "ghostInitMethod") ?? Attr(callerEl, "ghostInitMethod")); } + // B3: conditional display. / wrap content shown only when the condition + // passes (fails, for ifnot) — DataTree.ProcessSubpartNode cases "if"/"ifnot" over + // XmlVc.ConditionPasses. The condition is preserved as structured metadata; the + // composer evaluates it per object. + case "if": + case "ifnot": + { + var condition = TryParseCondition(contentEl, contentEl.Name.LocalName == "ifnot", + stableId, diagnostics); + if (condition == null) + return null; // unsupported condition form; diagnostic already raised + + var children = new List(); + BuildConditionalChildren(contentEl, parts, className, layoutType, stableId, indented, + children, diagnostics); + return new ViewNode(stableId, ViewNodeKind.Conditional, label, abbreviation, + Attr(contentEl, "field"), null, EditorClassification.GroupingNone, null, + visibility, expansion, indented, null, children, localizationKey, automationId, + routing, menuId: menuId, contextMenuId: contextMenuId, hotlinksId: hotlinksId, + condition: condition); + } + + // B3: holds branches (first passing one renders) and an optional + // trailing — DataTree.ProcessSubpartNode case "choice". + case "choice": + { + var branches = new List(); + foreach (var clause in contentEl.Elements()) + { + var branchId = $"{stableId}/#{branches.Count}"; + ViewCondition branchCondition = null; + if (clause.Name.LocalName == "where") + { + branchCondition = TryParseCondition(clause, false, branchId, diagnostics); + // One unevaluable where would mis-select a later branch/otherwise; drop the + // whole choice (the diagnostic from TryParseCondition records why). + if (branchCondition == null) + return null; + } + else if (clause.Name.LocalName != "otherwise") + { + // Legacy throws "elements in choice must be or ". + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, + "unknown-part-content", + $" child <{clause.Name.LocalName}> is not where/otherwise and is not imported.", + branchId)); + continue; + } + + var branchChildren = new List(); + BuildConditionalChildren(clause, parts, className, layoutType, branchId, indented, + branchChildren, diagnostics); + branches.Add(new ViewNode(branchId, ViewNodeKind.Conditional, null, null, + Attr(clause, "field"), null, EditorClassification.GroupingNone, null, + ViewVisibility.Always, ViewExpansion.NotApplicable, indented, null, + branchChildren, condition: branchCondition)); + } + + return new ViewNode(stableId, ViewNodeKind.ChoiceGroup, label, abbreviation, null, + null, EditorClassification.GroupingNone, null, visibility, expansion, indented, + null, branches, localizationKey, automationId, routing, menuId: menuId, + contextMenuId: contextMenuId, hotlinksId: hotlinksId); + } + default: diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "unknown-part-content", $"Unsupported part content element '{contentEl.Name.LocalName}'.", stableId)); @@ -181,6 +441,90 @@ private ViewNode BuildNode( } } + // B3: a conditional wrapper's children are part content (//, possibly nested + // conditionals) inside part definitions, or / refs at layout level — exactly the + // child kinds DataTree.ProcessPartChildren dispatches. + private void BuildConditionalChildren( + XElement container, + IPartResolver parts, + string className, + string layoutType, + string parentPath, + bool indented, + List output, + List diagnostics) + { + foreach (var child in container.Elements()) + { + switch (child.Name.LocalName) + { + case "part": + case "indent": + ProcessContainer(new[] { child }, parts, className, layoutType, parentPath, + indented, output, diagnostics); + break; + default: + { + var node = BuildNode(child, child, parts, className, layoutType, + $"{parentPath}/#{output.Count}", indented, diagnostics); + if (node != null) + output.Add(node); + break; + } + } + } + } + + // B3: parse an // element's condition attributes into the typed + // ViewCondition, or report conditional-dropped and return null when the element uses a + // condition form outside the supported (detail-lane) vocabulary — never half-evaluate. + private static ViewCondition TryParseCondition( + XElement el, bool negated, string stableId, List diagnostics) + { + foreach (var attr in el.Attributes()) + { + var name = attr.Name.LocalName; + if (!HandledConditionAttributes.Contains(name)) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "conditional-dropped", + $"Conditional <{el.Name.LocalName}> uses unsupported condition attribute '{name}'; its content is not imported.", + stableId)); + return null; + } + + // $-substituted values (e.g. target='$fieldName' inside ) and slash field + // paths need runtime substitution/path hops the composer does not perform (B9). + if (attr.Value.IndexOf('$') >= 0 || (name == "field" && attr.Value.IndexOf('/') >= 0)) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "conditional-dropped", + $"Conditional <{el.Name.LocalName}> condition attribute '{name}'='{attr.Value}' needs runtime substitution; its content is not imported.", + stableId)); + return null; + } + } + + return new ViewCondition( + negated, + Attr(el, "target"), + Attr(el, "is"), + Attr(el, "excludesubclasses") == "true" || Attr(el, "excludesubclasses") == "yes", + Attr(el, "field"), + boolEquals: Attr(el, "boolequals") == null ? (bool?)null : Attr(el, "boolequals") == "true", + intEquals: ParseNullableInt(Attr(el, "intequals")), + intLessThan: ParseNullableInt(Attr(el, "intlessthan")), + intGreaterThan: ParseNullableInt(Attr(el, "intgreaterthan")), + intMemberOf: Attr(el, "intmemberof"), + lengthAtLeast: ParseNullableInt(Attr(el, "lengthatleast")), + lengthAtMost: ParseNullableInt(Attr(el, "lengthatmost")), + guidEquals: Attr(el, "guidequals")); + } + + private static int? ParseNullableInt(string value) + => value != null && int.TryParse(value, System.Globalization.NumberStyles.Integer, + System.Globalization.CultureInfo.InvariantCulture, out var parsed) + ? parsed + : (int?)null; + // Inline children are concrete // elements nested directly inside a grouping slice. private void BuildInlineChildren( IEnumerable childElements, @@ -212,8 +556,18 @@ private void BuildInjectedChildren( List output, List diagnostics) { - foreach (var child in callerEl.Elements("part")) + foreach (var child in callerEl.Elements()) { + if (child.Name.LocalName != "part") + { + // E.g. an wrapper under an obj/seq caller; its nested parts are not expanded + // here. Report rather than silently drop (task 4.9). + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Warning, "injected-child-dropped", + $"Caller child <{child.Name.LocalName}> under an object/sequence part is not imported.", + $"{parentPath}/#{output.Count}")); + continue; + } + var stableId = $"{parentPath}/#{output.Count}"; var refName = (string)child.Attribute("ref"); if (string.IsNullOrEmpty(refName)) @@ -268,6 +622,53 @@ private static ViewNode MakeLeaf( private static string Attr(XElement el, string name) => (string)el.Attribute(name); + // Task 4.9: one diagnostic per attribute the importer does not consume. Functional drops + // (menus, ghost lines) are warnings; presentational drops (style, separators, numbering) are info. + private static void ReportUnhandledAttributes( + XElement el, HashSet handled, string role, string stableId, + List diagnostics) + { + foreach (var attr in el.Attributes()) + { + var name = attr.Name.LocalName; + if (handled.Contains(name)) + { + continue; + } + + var severity = FunctionalDroppedAttributes.Contains(name) + ? ViewDiagnosticSeverity.Warning + : ViewDiagnosticSeverity.Info; + diagnostics.Add(new ViewDiagnostic(severity, "unhandled-attribute", + $"Attribute '{name}'='{attr.Value}' on <{el.Name.LocalName}> ({role}) is not imported.", + stableId)); + } + } + + // Task 4.9: handled attributes whose values use runtime substitution ($param, {0}) are consumed + // literally by the importer; flag them so substitution semantics are not silently lost. + private static void ReportSubstitutionValues( + XElement el, HashSet handled, string stableId, List diagnostics) + { + foreach (var attr in el.Attributes()) + { + var name = attr.Name.LocalName; + if (!handled.Contains(name)) + { + continue; + } + + if (attr.Value.IndexOf('$') < 0 && !attr.Value.Contains("{0}")) + { + continue; + } + + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Info, "param-substitution", + $"Attribute '{name}'='{attr.Value}' on <{el.Name.LocalName}> uses runtime substitution the importer does not expand.", + stableId)); + } + } + private static SurfaceRouting ParseRouting(string value) { switch (value) diff --git a/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs index c9538651d4..170ad3d54e 100644 --- a/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs +++ b/Src/Common/FwAvaloniaPreviewHost/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs @@ -3,8 +3,10 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System; +using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Linq; using System.Threading; using System.Windows.Automation; using NUnit.Framework; @@ -65,6 +67,37 @@ public void PreviewHost_MainWindowAndCoreControls_ExposeStableAutomationIds() Assert.That(FindByAutomationId(window, "MorphTypeChooser.Button"), Is.Not.Null); } + /// + /// Task 7.11 (names/order lane): the realized Avalonia surface exposes the same field labels + /// the legacy DataTree slices carry, as UIA Names, in the legacy top-to-bottom order — so a + /// screen reader announces the same vocabulary on both surfaces. The keyboard-traversal + /// assistive smoke extends this once the chooser-dialog path (6.3/3.16) lands. + /// + [Test] + public void PreviewHost_UiaTree_ExposesLegacyFieldLabels_InLegacyOrder() + { + EnsureInteractiveDesktop(); + var window = StartPreviewHostAndWaitForWindow(); + + var expectedLegacyLabels = new[] { "Lexeme Form", "Morph Type", "Gloss" }; + var all = window.FindAll(TreeScope.Descendants, Condition.TrueCondition); + var names = new List(); + foreach (AutomationElement element in all) + { + var name = element.Current.Name; + if (!string.IsNullOrEmpty(name)) + names.Add(name); + } + + var positions = expectedLegacyLabels + .Select(label => names.FindIndex(n => n.StartsWith(label, StringComparison.Ordinal))) + .ToList(); + Assert.That(positions, Is.All.GreaterThanOrEqualTo(0), + "every legacy slice label must be announced by the Avalonia surface: " + + string.Join(" | ", names.Take(40))); + Assert.That(positions, Is.Ordered, "labels appear in the legacy top-to-bottom order"); + } + [Test] public void PreviewHost_MorphTypeButton_Invoke_ShowsPopupList() { diff --git a/Src/Common/FwUtils/FwUtilsTests/VersionInfoProviderTests.cs b/Src/Common/FwUtils/FwUtilsTests/VersionInfoProviderTests.cs new file mode 100644 index 0000000000..6618bd3423 --- /dev/null +++ b/Src/Common/FwUtils/FwUtilsTests/VersionInfoProviderTests.cs @@ -0,0 +1,89 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Reflection; +using System.Text.RegularExpressions; +using NUnit.Framework; + +namespace SIL.FieldWorks.Common.FwUtils +{ + /// + /// How versions and copyrights are read out of the generated version attributes + /// (CommonAssemblyInfo.cs, generated from CommonAssemblyInfoTemplate.cs + MasterVersionInfo.txt). + /// This test assembly links the generated file, so these tests run against the real attribute + /// shapes the product ships — including the empty-FWBETAVERSION informational version + /// ("9.x.y.NNNNN NNNNN " with a trailing space). + /// + [TestFixture] + public class VersionInfoProviderTests + { + private static Assembly TestAssembly => typeof(VersionInfoProviderTests).Assembly; + + // The year range stamped into this build by the Substitute task, e.g. "2002-2026". + private static string AssemblyYearRange() + { + var attribute = (AssemblyCopyrightAttribute)Attribute.GetCustomAttribute( + TestAssembly, typeof(AssemblyCopyrightAttribute)); + Assert.That(attribute, Is.Not.Null, "the test assembly must link the generated CommonAssemblyInfo.cs"); + var match = Regex.Match(attribute.Copyright, @"\d{4}-\d{4}"); + Assert.That(match.Success, Is.True, "unexpected copyright shape: " + attribute.Copyright); + return match.Value; + } + + [Test] + public void CopyrightString_ComesFromTheAssembly() + { + var provider = new VersionInfoProvider(TestAssembly, true); + Assert.That(provider.CopyrightString, + Is.EqualTo($"Copyright © {AssemblyYearRange()} SIL International")); + } + + [Test] + public void CopyrightString_Sensitive_KeepsTheAssemblyYears_AndDropsSilIdentification() + { + var provider = new VersionInfoProvider(TestAssembly, false); + Assert.That(provider.CopyrightString, Does.Not.Contain("SIL"), + "sensitive mode must hide SIL-identifying information"); + Assert.That(provider.CopyrightString, Is.EqualTo($"Copyright © {AssemblyYearRange()}"), + "sensitive mode must keep the years current from the assembly, not a year hardcoded in source"); + } + + [Test] + public void FallbackCopyrightStrings_AreNotFrozenInThePast() + { + // The fallbacks (used when an assembly carries no copyright attribute) must not trail + // the running year; they were once hardcoded "2002-2021" and shipped stale for years. + StringAssert.Contains(DateTime.Now.Year.ToString(), VersionInfoProvider.kDefaultCopyrightString); + StringAssert.Contains(DateTime.Now.Year.ToString(), VersionInfoProvider.kSensitiveCopyrightString); + } + + [Test] + public void ApplicationVersion_ReportsTheProvidersAssembly_NotTheEntryAssembly() + { + // Under a test runner the entry assembly is testhost, not FieldWorks; a provider + // constructed for a specific assembly must report THAT assembly's version. + var provider = new VersionInfoProvider(TestAssembly, true); + Assert.That(provider.ApplicationVersion, Does.Contain(provider.NumericAppVersion)); + } + + [Test] + public void MajorVersion_HasNoTrailingWhitespace_WhenThereIsNoBetaSuffix() + { + // With FWBETAVERSION empty the informational version ends in a space + // ("9.3.10.46183 46183 "); the parsed display strings must not inherit it. + var provider = new VersionInfoProvider(TestAssembly, true); + Assert.That(provider.MajorVersion, Is.EqualTo(provider.MajorVersion.TrimEnd())); + } + + [Test] + public void ApplicationVersion_CarriesTheEncodedBuildDate() + { + var provider = new VersionInfoProvider(TestAssembly, true); + // The second token of the informational version is an OADate day number; it must come + // back out as an ISO date, not as the raw number. + Assert.That(provider.ApplicationVersion, Does.Match(@"\d{4}-\d{2}-\d{2}")); + } + } +} diff --git a/Src/Common/FwUtils/VersionInfoProvider.cs b/Src/Common/FwUtils/VersionInfoProvider.cs index 113035e6eb..0c415b9232 100644 --- a/Src/Common/FwUtils/VersionInfoProvider.cs +++ b/Src/Common/FwUtils/VersionInfoProvider.cs @@ -19,10 +19,14 @@ public class VersionInfoProvider { internal static DateTime DefaultBuildDate = new DateTime(2001, 06, 23); - /// Default copyright string if no assembly could be found - public const string kDefaultCopyrightString = "Copyright (c) 2002-2021 SIL International"; - /// Copyright string to use in sensitive areas (i.e. when m_fShowSILInfo is true) - public const string kSensitiveCopyrightString = "Copyright (c) 2002-2021"; + /// Default copyright string if the assembly carries no copyright attribute. + /// Computed so it can never ship frozen at the year somebody last edited this file. + public static readonly string kDefaultCopyrightString = + $"Copyright (c) 2002-{DateTime.Now.Year} SIL International"; + /// Copyright string to use in sensitive areas (i.e. when m_fShowSILInfo is false) + /// and the assembly carries no copyright attribute + public static readonly string kSensitiveCopyrightString = + $"Copyright (c) 2002-{DateTime.Now.Year}"; private readonly Assembly m_assembly; private readonly bool m_fShowSILInfo; @@ -173,9 +177,12 @@ public string ApplicationVersion { get { - // Set the application version text - var appVersion = InternalProductVersion; - ParseInformationalVersion(m_assembly, out _, out var productDate); + // Set the application version text from the assembly this provider was built for; + // InternalProductVersion (the entry assembly) is only a fallback for assemblies + // that carry no version attributes of their own. + ParseInformationalVersion(m_assembly, out var appVersion, out var productDate); + if (string.IsNullOrEmpty(appVersion)) + appVersion = InternalProductVersion; string bitness; switch (IntPtr.Size) { @@ -219,7 +226,10 @@ private static void ParseInformationalVersion(Assembly assembly, out string prod { case 3: { - productType = " " + versionParts[2]; + // FWBETAVERSION is empty for stable builds, leaving a trailing space in the + // informational version; don't let it leak into the parsed display strings. + if (!string.IsNullOrWhiteSpace(versionParts[2])) + productType = " " + versionParts[2].Trim(); goto case 2; } case 2: @@ -250,14 +260,16 @@ public string MajorVersion { get { - // Set the FieldWorks version text + // Set the FieldWorks version text. Parts: MAJOR.MINOR.REVISION.BUILDNUMBER STABILITY; + // STABILITY (Alpha/Beta/RC) is absent in stable builds, so index defensively and trim + // rather than leaking a placeholder or a trailing space into the About box. ParseInformationalVersion(m_assembly, out var productVersion, out _); - // Fill the expected parts to document and avoid a crash if we get an odd informational version - var versionParts = new [] {"MAJOR", "MINOR", "REVISION", "BUILDNUMBER", "STABILITY"}; var realParts = productVersion.Split('.', ' '); - Array.Copy(realParts, versionParts, Math.Min(realParts.Length, versionParts.Length)); + var major = realParts.Length > 0 ? realParts[0] : "?"; + var minor = realParts.Length > 1 ? realParts[1] : "?"; + var stability = realParts.Length > 4 ? realParts[4] : string.Empty; - return string.Format(FwUtilsStrings.kstidMajorVersionFmt, $"{versionParts[0]}.{versionParts[1]} {versionParts[4]}"); + return string.Format(FwUtilsStrings.kstidMajorVersionFmt, $"{major}.{minor} {stability}".TrimEnd()); } } @@ -286,22 +298,16 @@ public string CopyrightString get { // Get copyright information from assembly info. By doing this we don't have - // to update the splash screen each year. - string copyRight; + // to update the splash screen each year - in EITHER mode: the sensitive variant + // derives from the same attribute so its years stay current too. + string copyRight = null; + object[] attributes = m_assembly.GetCustomAttributes(typeof(AssemblyCopyrightAttribute), false); + if (attributes != null && attributes.Length > 0) + copyRight = ((AssemblyCopyrightAttribute)attributes[0]).Copyright; + if (string.IsNullOrEmpty(copyRight)) + copyRight = m_fShowSILInfo ? kDefaultCopyrightString : kSensitiveCopyrightString; if (!m_fShowSILInfo) - copyRight = kSensitiveCopyrightString; - else - { - object[] attributes = m_assembly.GetCustomAttributes(typeof(AssemblyCopyrightAttribute), false); - if (attributes != null && attributes.Length > 0) - copyRight = ((AssemblyCopyrightAttribute)attributes[0]).Copyright; - else - { - // if we can't find it in the assembly info, use generic one (which - // might be out of date) - copyRight = kDefaultCopyrightString; - } - } + copyRight = copyRight.Replace("SIL International", string.Empty).TrimEnd(); // 00a9 is the copyright sign return copyRight.Replace("(c)", "\u00a9"); } diff --git a/Src/Common/SimpleRootSite/TsStringWrapper.cs b/Src/Common/SimpleRootSite/TsStringWrapper.cs index 96be9fb791..4a130a2cf5 100644 --- a/Src/Common/SimpleRootSite/TsStringWrapper.cs +++ b/Src/Common/SimpleRootSite/TsStringWrapper.cs @@ -18,11 +18,15 @@ namespace SIL.FieldWorks.Common.RootSites { /// ---------------------------------------------------------------------------------------- /// - /// Wraps a ITsString object so that it can be serialized to/from the clipboard + /// Wraps a ITsString object so that it can be serialized to/from the clipboard. + /// This type (with the OS clipboard format) is the cross-framework + /// rich-text clipboard contract: legacy native-Views surfaces write/read it in + /// EditingHelper, and the Avalonia coexistence bridge (FwTsStringClipboard in + /// xWorks, task 3.13) speaks the same format so copy/paste round-trips between frameworks. /// /// ---------------------------------------------------------------------------------------- [Serializable] - internal class TsStringWrapper : ISerializable + public class TsStringWrapper : ISerializable { [NonSerialized] private readonly string m_Xml; @@ -37,6 +41,29 @@ public TsStringWrapper(ITsString tsString, ILgWritingSystemFactory writingSystem m_Xml = TsStringUtils.GetXmlRep(tsString, writingSystemFactory, 0); } + /// -------------------------------------------------------------------------------- + /// + /// Creates a wrapper directly from an already-serialized TsString XML representation + /// (as produced by TsStringUtils.GetXmlRep). Used by the cross-framework + /// clipboard bridge, which carries the XML lane without an ITsString in hand. + /// + /// -------------------------------------------------------------------------------- + public static TsStringWrapper FromXml(string xml) + { + return new TsStringWrapper(xml); + } + + private TsStringWrapper(string xml) + { + m_Xml = xml; + } + + /// The serialized TsString XML representation this wrapper carries. + public string Xml + { + get { return m_Xml; } + } + /// -------------------------------------------------------------------------------- /// /// Initializes a new instance of the class. diff --git a/Src/CommonAssemblyInfoTemplate.cs b/Src/CommonAssemblyInfoTemplate.cs index e1b883ffa2..04d584f0c1 100644 --- a/Src/CommonAssemblyInfoTemplate.cs +++ b/Src/CommonAssemblyInfoTemplate.cs @@ -1,5 +1,5 @@ /*---------------------------------------------------------------------------------------------- -Copyright (c) 2002-2021 SIL International +Copyright (c) 2002-$YEAR SIL International This software is licensed under the LGPL, version 2.1 or later (http://www.gnu.org/licenses/lgpl-2.1.html) diff --git a/Src/XCore/xWindow.cs b/Src/XCore/xWindow.cs index eb6b73f9e5..a971f48bf6 100644 --- a/Src/XCore/xWindow.cs +++ b/Src/XCore/xWindow.cs @@ -968,6 +968,30 @@ public void ShowContextMenu(string[] menuIds, /*out ChoiceGroup group,*/ Point l ((IUIMenuAdapter)m_menuBarAdapter).ShowContextMenu(group, location, temporaryColleagueParam, sequencer); } + /// + /// Materializes the same merged ChoiceGroup that ShowContextMenu(string[]) shows, WITHOUT + /// creating any WinForms UI — for hosts that render the menu themselves (e.g. the Avalonia + /// lexical-edit surface) while keeping xCore display/dispatch semantics. Returns null when + /// none of the ids resolve. Callers should PopulateNow() before iterating. + /// + public ChoiceGroup GetContextMenuChoiceGroup(string[] menuIds) + { + CheckDisposed(); + + List nodes = new List(menuIds.Length); + foreach (string m in menuIds) + { + if (string.IsNullOrEmpty(m)) + continue; + XmlNode node = GetContextMenuNodeFromMenuId(m); + if (node != null) + nodes.Add(node); + } + if (nodes.Count == 0) + return null; + return new ChoiceGroup(m_mediator, m_propertyTable, m_menuBarAdapter, nodes, null); + } + /// /// returns the configuration node for the given menu id. /// diff --git a/Src/xWorks/AvaloniaCompanionSlices.cs b/Src/xWorks/AvaloniaCompanionSlices.cs new file mode 100644 index 0000000000..a53c7414c6 --- /dev/null +++ b/Src/xWorks/AvaloniaCompanionSlices.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using SIL.FieldWorks.Common.Framework.DetailControls; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.Reporting; +using SIL.Utils; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The identity of a layout slice whose legacy editor is a dynamically loaded custom slice + /// (editor="Custom" assemblyPath=... class=...) and which composed as a placeholder row + /// (an unsupported row, or a best-effort read-only rendering — e.g. the Messages slice's + /// field="Self" renders as read-only text). + /// is exactly the StableId of that row in the composed + /// , so promotion to the companion lane can remove the row. + /// + public sealed class ComposedCustomEditorField + { + public ComposedCustomEditorField(string fieldStableId, string className, string assemblyPath, + string label, int objectHvo) + { + FieldStableId = fieldStableId; + ClassName = className; + AssemblyPath = assemblyPath; + Label = label; + ObjectHvo = objectHvo; + } + + /// The composed region row this slice identity belongs to (StableId coordination). + public string FieldStableId { get; } + + /// The fully qualified legacy slice class (layout `class=`). + public string ClassName { get; } + + /// The dll the class loads from (layout `assemblyPath=`), e.g. LexEdDll.dll. + public string AssemblyPath { get; } + + /// The localized row label (e.g. "Messages"). + public string Label { get; } + + /// The LCModel object the slice binds to (the entry for the Messages slice). + public int ObjectHvo { get; } + } + + /// + /// The hybrid "companion strip" lane for designated WinForms-only custom slices: the Avalonia + /// surface is itself hosted in WinForms (PocWinFormsHostControl inside RecordEditView), so a + /// legacy slice whose editor cannot be rendered inside Avalonia (today: the Chorus Send/Receive + /// Messages notes bar, which hosts Chorus.UI.Notes.Bar.NotesBarView) is instantiated for real and + /// stacked in a WinForms strip above the Avalonia host instead of rendering as a grey + /// unsupported row. Pure selection/filtering logic lives here so it is unit-testable without a + /// RecordEditView; the host owns control lifetime. + /// + public static class AvaloniaCompanionSlices + { + /// The Chorus Send/Receive notes bar (LexEntryParts.xml part "LexEntry-Detail-Messages"). + public const string MessageSliceClassName = "SIL.FieldWorks.XWorks.LexEd.MessageSlice"; + + // The designated companion classes. Every other dynamic custom editor keeps the explicit + // unsupported row until it gets a real Avalonia mapping (blocker register B11). + private static readonly HashSet PromotedClassNames = new HashSet(StringComparer.Ordinal) + { + MessageSliceClassName + }; + + /// + /// Picks the composed custom-editor fields that are designated for companion-strip promotion. + /// + public static IReadOnlyList SelectPromotions( + IReadOnlyList customEditorFields) + { + if (customEditorFields == null || customEditorFields.Count == 0) + return Array.Empty(); + return customEditorFields + .Where(f => f != null && PromotedClassNames.Contains(f.ClassName)) + .ToList(); + } + + /// + /// Returns a model without the promoted rows (by StableId), so the Avalonia region no longer + /// shows the placeholder row for a slice the companion strip renders (or that + /// degraded to nothing because Chorus is unavailable). Returns the same instance when nothing + /// matches. + /// + public static LexicalEditRegionModel RemovePromotedFields(LexicalEditRegionModel model, + IEnumerable promotedStableIds) + { + if (model == null) + throw new ArgumentNullException(nameof(model)); + var ids = new HashSet(promotedStableIds ?? Enumerable.Empty(), StringComparer.Ordinal); + if (ids.Count == 0 || !model.Fields.Any(f => ids.Contains(f.StableId))) + return model; + return new LexicalEditRegionModel(model.ClassName, model.LayoutName, + model.Fields.Where(f => !ids.Contains(f.StableId)).ToList(), model.Diagnostics); + } + + /// + /// Instantiates the real legacy slice for a promoted field the way DataTree does for + /// editor="Custom" (SliceFactory.Create: DynamicLoader by assemblyPath/class, then the + /// DataTree install recipe Cache → Object → FinishInit; MessageSlice.FinishInit needs only + /// those and builds the NotesBarView into Slice.Control). Returns null and logs when the + /// slice cannot be created (e.g. Chorus/Send-Receive is unavailable) — the caller degrades + /// to showing nothing for the row. + /// + public static Slice CreateCompanionSlice(ComposedCustomEditorField binding, LcmCache cache) + { + if (binding == null) + throw new ArgumentNullException(nameof(binding)); + if (cache == null) + throw new ArgumentNullException(nameof(cache)); + + Slice slice = null; + try + { + if (!cache.ServiceLocator.ObjectRepository.TryGetObject(binding.ObjectHvo, out var obj)) + { + Logger.WriteEvent($"Companion slice '{binding.ClassName}': object {binding.ObjectHvo} is gone; skipping."); + return null; + } + + slice = DynamicLoader.CreateObject(binding.AssemblyPath, binding.ClassName) as Slice; + if (slice == null) + { + Logger.WriteEvent($"Companion slice '{binding.ClassName}' from '{binding.AssemblyPath}' is not a Slice; skipping."); + return null; + } + + slice.Cache = cache; + slice.Object = obj; + slice.FinishInit(); + return slice; + } + catch (Exception e) + { + // Graceful degradation: without a working Chorus system the Messages lane simply + // does not appear (legacy shows an empty bar at best); never take the view down. + Logger.WriteEvent($"Companion slice '{binding.ClassName}' unavailable; showing nothing for '{binding.Label}': {e}"); + slice?.Dispose(); + return null; + } + } + } +} diff --git a/Src/xWorks/AvaloniaRegionRefreshController.cs b/Src/xWorks/AvaloniaRegionRefreshController.cs new file mode 100644 index 0000000000..e8e47f5303 --- /dev/null +++ b/Src/xWorks/AvaloniaRegionRefreshController.cs @@ -0,0 +1,199 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The cross-surface refresh propagation gate, Avalonia side only (task 3.15). Both surfaces + /// share one LCModel cache, so consistency stands on the PropChanged notification loop: + /// this controller subscribes to the real notification bus and asks + /// the host to re-resolve/re-show the Avalonia region whenever a change lands inside the entry + /// the surface is displaying — whether it came from a legacy surface, F5/RefreshAllViews-driven + /// reloads, or any other writer. While the surface's own edit session is open, refreshes are + /// gated through an (suspend/pending, the LT-22414 + /// model) and delivered once on edit completion, so a half-typed edit is never stomped. + /// + /// Delivery is coalesced through the host's schedule delegate (review round 1): one + /// committed undo task or external bulk edit raises one PropChanged per changed property, and + /// recomposing the region synchronously per notification both froze the UI on bursts and + /// reentrantly tore down the view while Commit/Cancel were still on the stack. With a scheduler + /// a burst becomes ONE queued refresh that runs after the current call stack unwinds; without + /// one (tests, simple hosts) delivery stays synchronous. + /// + public sealed class AvaloniaRegionRefreshController : IVwNotifyChange, IDisposable + { + private readonly LcmCache _cache; + private readonly Func _currentRecord; + private readonly Func _isEditing; + private readonly Action _refresh; + private readonly ILexicalRefreshCoordinator _coordinator; + private readonly Action _schedule; + private bool _refreshQueued; + private bool _disposed; + + public AvaloniaRegionRefreshController( + LcmCache cache, + Func currentRecord, + Func isEditing, + Action refresh, + ILexicalRefreshCoordinator coordinator, + Action schedule = null) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _currentRecord = currentRecord ?? throw new ArgumentNullException(nameof(currentRecord)); + _isEditing = isEditing ?? throw new ArgumentNullException(nameof(isEditing)); + _refresh = refresh ?? throw new ArgumentNullException(nameof(refresh)); + _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + _schedule = schedule; + cache.DomainDataByFlid.AddNotification(this); + } + + /// + public void PropChanged(int hvo, int tag, int ivMin, int cvIns, int cvDel) + { + // A queued refresh recomposes from CURRENT domain state, so further notifications are + // already covered — skip even the relevance walk (PropChanged fires app-wide). + if (_disposed || _refreshQueued || !IsRelevant(hvo)) + return; + + if (_isEditing()) + { + // The surface's own session is writing (or an external edit raced it): hold the + // refresh until the edit completes rather than stomping in-progress input. + if (!_coordinator.IsSuspended) + _coordinator.BeginSuspend(); + _coordinator.RequestRefresh(); + return; + } + + if (_coordinator.IsSuspended) + { + // Editing ended between notifications; deliver the pending refresh (covers it) now. + if (_coordinator.EndSuspend()) + ScheduleRefresh(); + return; + } + + if (_coordinator.RequestRefresh()) + ScheduleRefresh(); + } + + /// + /// Called by the host when its edit session committed or cancelled: delivers any refresh that + /// was held while editing. + /// + public void NotifyEditCompleted() + { + if (_disposed) + return; + if (_coordinator.IsSuspended && _coordinator.EndSuspend()) + ScheduleRefresh(); + } + + /// + /// Host-initiated refresh (e.g. after a commit/cancel completed) routed through the SAME + /// coalesced, editing-aware queue as PropChanged deliveries, so a completion plus a + /// notification burst still recomposes exactly once. + /// + public void RequestRefresh() + { + if (_disposed) + return; + ScheduleRefresh(); + } + + /// + /// Called by the host when it is about to re-show the region itself anyway: drops any held + /// delivery so completion does not double the recompose. + /// + public void DiscardHeldRefresh() + { + if (_disposed) + return; + if (_coordinator.IsSuspended) + _coordinator.EndSuspend(); + } + + // Coalesce: one queued delivery covers the whole burst. The runner re-checks state because + // the world can change between queueing and running (host disposed; user started typing — + // then the refresh converts back into a held delivery instead of stomping the edit). + private void ScheduleRefresh() + { + if (_refreshQueued) + return; + _refreshQueued = true; + void Runner() + { + // _refreshQueued stays true UNTIL the refresh completes: a rebuild can itself raise + // PropChanged (e.g. a settle-commit inside it), and those notifications are already + // covered — the recompose reads current domain state — so they must coalesce into + // this delivery instead of queueing a second identical one (review round 2). + try + { + if (_disposed) + return; + if (_isEditing()) + { + if (!_coordinator.IsSuspended) + _coordinator.BeginSuspend(); + _coordinator.RequestRefresh(); + return; + } + _refresh(); + } + finally + { + _refreshQueued = false; + } + } + + if (_schedule != null) + { + try + { + _schedule(Runner); + } + catch + { + // If the host scheduler rejects the work, the runner will never fire; leaving + // the flag set would wedge the queue (no refresh could ever be scheduled again). + _refreshQueued = false; + throw; + } + } + else + { + Runner(); + } + } + + // A change is relevant when the changed object is, or is owned by, the entry on display. + private bool IsRelevant(int hvo) + { + var current = _currentRecord(); + if (current == null) + return false; + if (hvo == current.Hvo) + return true; + + if (!_cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var changed)) + return false; + var owningEntry = changed as ILexEntry ?? changed.OwnerOfClass(); + return owningEntry != null && owningEntry.Hvo == current.Hvo; + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _cache.DomainDataByFlid.RemoveNotification(this); + } + } +} diff --git a/Src/xWorks/DTMenuHandler.cs b/Src/xWorks/DTMenuHandler.cs index 5bd344373b..5fe7328bcd 100644 --- a/Src/xWorks/DTMenuHandler.cs +++ b/Src/xWorks/DTMenuHandler.cs @@ -861,8 +861,11 @@ public virtual bool OnDisplayDataTreeInsert(object commandObject, slice = m_dataEntryForm.FieldAt(0); int index = -1; + // 15.4: the hidden command-routing adapter tree counts as active — the user-visible + // surface (Avalonia) is elsewhere, and the insert itself never needs visibility. if (command != null && slice != null && !slice.IsDisposed && - m_dataEntryForm.Visible && this.CanInsert(command, slice, out index)) + (m_dataEntryForm.Visible || m_dataEntryForm.IsExternalCommandAdapter) && + this.CanInsert(command, slice, out index)) { display.Enabled = true; string toolTipInsert = display.Text.Replace("_", string.Empty); // strip any menu keyboard accelerator marker; diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs new file mode 100644 index 0000000000..ea04fdfec8 --- /dev/null +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -0,0 +1,1347 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Xml.Linq; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Application; +using SIL.LCModel.Application.ApplicationServices; +using SIL.LCModel.Core.Cellar; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// A composed full-entry region: the renderable model plus its LCModel-bound edit context. + public sealed class ComposedEntryRegion + { + public ComposedEntryRegion(LexicalEditRegionModel model, IRegionEditContext editContext, + IReadOnlyList customEditorFields = null) + { + Model = model; + EditContext = editContext; + CustomEditorFields = customEditorFields ?? Array.Empty(); + } + + public LexicalEditRegionModel Model { get; } + + public IRegionEditContext EditContext { get; } + + /// + /// The legacy class/assembly identities of the dynamically loaded custom slices that composed + /// as placeholder rows (unsupported or best-effort read-only), keyed back to the model by each + /// row's StableId. The host uses this to promote designated WinForms-only slices (the Chorus + /// Messages notes bar) to the hybrid companion strip instead of showing the placeholder row + /// (see AvaloniaCompanionSlices). + /// + public IReadOnlyList CustomEditorFields { get; } + } + + /// + /// Composes the COMPLETE Lexical Edit view for an entry (sections 6/7): walks the compiled + /// `LexEntry/Normal` typed definition the same way legacy DataTree walks layouts — expanding + /// object/sequence nodes across objects by compiling each target's own layout (with the legacy + /// base-class walk), emitting section headers, indentation, per-writing-system editable text + /// fields, the morph-type chooser, read-only reference rows, and `ifdata` hiding — every field + /// bound to LCModel through metadata (class/field → flid) and editable through the fenced + /// session. Labels localize through the same lane legacy slices use. + /// Unsupported constructs render an explicit unsupported row (visibility=always) or are skipped + /// (ifdata), never silently mis-rendered; compile diagnostics ride the region model. + /// + public static class FullEntryRegionComposer + { + private const int MaxDepth = 6; + private static readonly ViewDefinitionCompiler Compiler = new ViewDefinitionCompiler(); + private static readonly Lazy Sources = new Lazy(LoadSources); + + // Review finding A (observable memoization): counts the expensive snapshot builds (layout + // lookup + layout.ToString() + fingerprint + compile). A repeat compose must not grow it. + private static int s_snapshotCompileCount; + + internal static int SnapshotCompileCount => s_snapshotCompileCount; + + // Review finding A: the loaded sources are immutable for the process lifetime, so the layout + // lookup is indexed once and compiled definitions are memoized per (starting class, layout) + // — repeat composes (and the per-item menu peeks below) never rebuild/re-fingerprint the + // ~300KB parts snapshot. Class ids and the class hierarchy are fixed LCModel metadata, so + // the memo is safe across caches. + private sealed class CompilerSources + { + public string PartsXml; + public Dictionary<(string ClassName, string Type, string Name), XElement> LayoutIndex; + public readonly ConcurrentDictionary<(int ClassId, string LayoutName), ViewDefinitionModel> CompiledModels + = new ConcurrentDictionary<(int, string), ViewDefinitionModel>(); + } + + public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false) + { + if (entry == null) throw new ArgumentNullException(nameof(entry)); + if (cache == null) throw new ArgumentNullException(nameof(cache)); + + var root = CompileForObject(cache, entry, "Normal"); + if (root == null) + return null; + + var state = new ComposeState(cache, showHiddenFields); + foreach (var node in root.Roots) + state.Walk(node, entry, 0); + + var context = new ComposedRegionEditContext(cache, entry, state.TextSetters, state.OptionSetters); + var model = new LexicalEditRegionModel("LexEntry", "Normal", state.Fields, root.Diagnostics); + return new ComposedEntryRegion(model, context, state.CustomEditorFields); + } + + private sealed class ComposeState + { + private readonly LcmCache _cache; + private readonly ISilDataAccess _sda; + private readonly IFwMetaDataCacheManaged _mdc; + private readonly HashSet<(int hvo, string layout)> _visited = new HashSet<(int, string)>(); + + public readonly List Fields = new List(); + public readonly Dictionary> TextSetters + = new Dictionary>(StringComparer.Ordinal); + public readonly Dictionary> OptionSetters + = new Dictionary>(StringComparer.Ordinal); + // Companion lane: the unsupported rows that are really legacy dynamic custom slices, + // keyed by the row's StableId (see ComposedEntryRegion.CustomEditorFields). + public readonly List CustomEditorFields + = new List(); + + private readonly bool _showHidden; + // Finding A: per-compose memos — the morph-type option list is identical for every + // IMoForm, and an item layout's menu/hotlinks binding is identical per (class, layout). + private List _morphTypeOptions; + private readonly Dictionary<(int ClassId, string LayoutName), (string MenuId, string HotlinksId)> _itemMenuBindings + = new Dictionary<(int, string), (string, string)>(); + + public ComposeState(LcmCache cache, bool showHiddenFields) + { + _cache = cache; + _showHidden = showHiddenFields; + _sda = cache.DomainDataByFlid; + _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; + } + + // Viewing parity: "show hidden fields" surfaces visibility=never fields and keeps empty + // ifdata fields visible, exactly like legacy m_fShowAllFields. + private bool IsHidden(ViewNode node) => node.Visibility == ViewVisibility.Never && !_showHidden; + + private bool HideWhenEmpty(ViewNode node) => node.Visibility == ViewVisibility.IfData && !_showHidden; + + public void Walk(ViewNode node, ICmObject obj, int depth) + { + if (IsHidden(node) || depth > MaxDepth) + return; + + switch (node.Kind) + { + case ViewNodeKind.Field: + WalkField(node, obj, depth); + break; + case ViewNodeKind.Group: + WalkGroup(node, obj, depth); + break; + case ViewNodeKind.ObjectAtom: + WalkObjectAtom(node, obj, depth); + break; + case ViewNodeKind.Sequence: + WalkSequence(node, obj, depth); + break; + case ViewNodeKind.CustomFieldPlaceholder: + // B1 (xml-retirement-blockers): runtime expansion of `customFields="here"` from + // live MDC metadata. The `` compile-time lane stays 9.2/9.3. + WalkCustomFields(node, obj, depth); + break; + case ViewNodeKind.Conditional: + // B3: legacy / — content composes only when the per-object condition + // passes (DataTree.ProcessSubpartNode cases "if"/"ifnot"). + WalkConditional(node, obj, depth); + break; + case ViewNodeKind.ChoiceGroup: + // B3: legacy — first passing (or the ) only. + WalkChoiceGroup(node, obj, depth); + break; + } + } + + // B3: / wrapper — evaluate and pass through; failing branches drop entirely. + private void WalkConditional(ViewNode node, ICmObject obj, int depth) + { + if (node.Condition != null && !ConditionPasses(node.Condition, obj)) + return; + foreach (var child in node.Children) + Walk(child, obj, depth); + } + + // B3: semantics from DataTree.ProcessSubpartNode case "choice": expand only the + // FIRST whose condition passes; an branch (null condition) always + // passes and stops the scan. + private void WalkChoiceGroup(ViewNode node, ICmObject obj, int depth) + { + foreach (var branch in node.Children) + { + if (branch.Kind != ViewNodeKind.Conditional) + continue; + if (branch.Condition != null && !ConditionPasses(branch.Condition, obj)) + continue; + foreach (var child in branch.Children) + Walk(child, obj, depth); + break; + } + } + + // B3: evaluate the imported condition per object, mirroring XmlVc.ConditionPasses exactly + // as the legacy detail lane invokes it (DataTree.cs:2639-2696 over XmlVc.cs:3276-3290): + // resolve the target object, then every test present must pass; negates the result. + private bool ConditionPasses(ViewCondition condition, ICmObject obj) + { + var passes = EvaluateCondition(condition, obj); + return condition.Negated ? !passes : passes; + } + + private bool EvaluateCondition(ViewCondition condition, ICmObject obj) + { + // Target hop (XmlVc.GetActualTarget): "this" (default), "owner", or an atomic field. + var target = obj; + if (!string.IsNullOrEmpty(condition.Target) + && !string.Equals(condition.Target, "this", StringComparison.OrdinalIgnoreCase)) + { + if (string.Equals(condition.Target, "owner", StringComparison.OrdinalIgnoreCase)) + { + target = obj.Owner; + } + else + { + var targetFlid = GetFlid(obj, condition.Target); + var targetHvo = targetFlid == 0 ? 0 : _sda.get_ObjectProp(obj.Hvo, targetFlid); + target = targetHvo == 0 + || !_cache.ServiceLocator.ObjectRepository.TryGetObject(targetHvo, out var resolved) + ? null + : resolved; + } + + // Legacy treats a missing target object as "can't have the expected value". + if (target == null) + return false; + } + + // is= class test with the subclass walk (XmlVc.IsConditionPasses). + if (!string.IsNullOrEmpty(condition.IsClass) + && !IsClassOrSubclass(target.ClassID, condition.IsClass, condition.ExcludeSubclasses)) + { + return false; + } + + // lengthatleast/lengthatmost (XmlVc.LengthConditionsPass: vector size; atomic 0/1). + if (condition.LengthAtLeast.HasValue || condition.LengthAtMost.HasValue) + { + var length = GetPropertyLength(target, condition.Field); + if (length < (condition.LengthAtLeast ?? 0) || length > (condition.LengthAtMost ?? int.MaxValue)) + return false; + } + + // boolequals (XmlVc.BoolEqualsConditionPasses via GetBoolValueFromCache: a missing + // object/field reads as the boolean value false, not as a failed condition). + if (condition.BoolEquals.HasValue) + { + var flid = GetFlid(target, condition.Field); + var value = flid != 0 && IntBoolPropertyConverter.GetBoolean(_sda, target.Hvo, flid); + if (value != condition.BoolEquals.Value) + return false; + } + + // intequals/intlessthan/intgreaterthan/intmemberof (XmlVc.GetValueFromCache reads 0 + // for a missing field — "rather arbitrary", but legacy-faithful). + if (condition.IntEquals.HasValue && GetIntValue(target, condition.Field) != condition.IntEquals.Value) + return false; + if (condition.IntLessThan.HasValue && GetIntValue(target, condition.Field) >= condition.IntLessThan.Value) + return false; + if (condition.IntGreaterThan.HasValue && GetIntValue(target, condition.Field) <= condition.IntGreaterThan.Value) + return false; + if (!string.IsNullOrEmpty(condition.IntMemberOf) && !IntMemberOfPasses(condition, target)) + return false; + + // guidequals (XmlVc.GuidEqualsConditionPasses): the atomic reference must point at the + // object with the literal guid; an empty reference compares as Guid.Empty. + if (!string.IsNullOrEmpty(condition.GuidEquals)) + { + var flid = GetFlid(target, condition.Field); + if (flid == 0 || !Guid.TryParse(condition.GuidEquals, out var expected)) + return false; + var hvoRef = _sda.get_ObjectProp(target.Hvo, flid); + var actual = hvoRef == 0 + ? Guid.Empty + : _sda.get_GuidProp(hvoRef, (int)CmObjectFields.kflidCmObject_Guid); + if (expected != actual) + return false; + } + + return true; + } + + private bool IsClassOrSubclass(int classId, string className, bool excludeSubclasses) + { + int expected; + try + { + expected = _mdc.GetClassId(className); + } + catch (Exception) + { + return false; + } + + if (classId == expected) + return true; + if (excludeSubclasses) + return false; + var baseId = _mdc.GetBaseClsId(classId); + while (baseId != 0) + { + if (baseId == expected) + return true; + var next = _mdc.GetBaseClsId(baseId); + if (next == baseId) + break; + baseId = next; + } + + return false; + } + + private int GetPropertyLength(ICmObject obj, string fieldName) + { + var flid = GetFlid(obj, fieldName); + if (flid == 0) + return 0; + switch ((CellarPropertyType)_mdc.GetFieldType(flid)) + { + case CellarPropertyType.OwningSequence: + case CellarPropertyType.OwningCollection: + case CellarPropertyType.ReferenceSequence: + case CellarPropertyType.ReferenceCollection: + return _sda.get_VecSize(obj.Hvo, flid); + case CellarPropertyType.OwningAtomic: + case CellarPropertyType.ReferenceAtomic: + return _sda.get_ObjectProp(obj.Hvo, flid) == 0 ? 0 : 1; + default: + return 0; + } + } + + private int GetIntValue(ICmObject obj, string fieldName) + { + var flid = GetFlid(obj, fieldName); + return flid == 0 ? 0 : _sda.get_IntProp(obj.Hvo, flid); + } + + private bool IntMemberOfPasses(ViewCondition condition, ICmObject target) + { + var value = GetIntValue(target, condition.Field); + foreach (var piece in condition.IntMemberOf.Split(',')) + { + if (int.TryParse(piece.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var member) + && member == value) + { + return true; + } + } + + return false; + } + + // B1: a layout can reach the same object through two placeholders (e.g. a persisted + // user override duplicating the marker); legacy dedups generated parts by sibling + // scan (DataTree.CheckCustomFieldsSibling) — here a (hvo, flid) set does the same. + private readonly HashSet<(int Hvo, int Flid)> _emittedCustomFields = new HashSet<(int, int)>(); + + // B1: expand the placeholder the way legacy DataTree.EnsureCustomFields + + // SliceFactory.MakeAutoCustomSlice do — enumerate the MDC's custom fields whose class + // is the object's class or a base class (legacy walks FieldDescription.FieldDescriptors, + // i.e. the MDC field list; sorted by flid here for determinism = creation order per + // class), synthesize a typed field node per custom field, and dispatch it through the + // normal walk so text rows ride the same setter registry/fenced session as authored + // fields. The legacy generated `` carries no visibility + // attribute, so every node is visibility=always: empty custom fields still render, + // with or without "show hidden fields". + private void WalkCustomFields(ViewNode placeholder, ICmObject obj, int depth) + { + var interestingClasses = new HashSet(); + var clsid = obj.ClassID; + while (clsid != 0) + { + interestingClasses.Add(clsid); + clsid = _mdc.GetBaseClsId(clsid); + } + + foreach (var flid in _mdc.GetFieldIds() + .Where(f => _mdc.IsCustom(f) && interestingClasses.Contains(_mdc.GetOwnClsId(f))) + .OrderBy(f => f)) + { + if (!_emittedCustomFields.Add((obj.Hvo, flid))) + continue; + Walk(MakeCustomFieldNode(placeholder, flid), obj, depth); + } + } + + // One synthesized node per custom field, typed like MakeAutoCustomSlice's editor + // switch: String/MultiString/MultiUnicode take the text lane (multi-WS per the field's + // WsSelector, resolved through the same legacy magic-ws pair WalkTextField uses); + // Integer stays an editable int row, GenDate a read-only formatted row, references + // read-only joined names (chooser write-back rides 6.3), and OwningAtomic StText + // read-only paragraphs — all via WalkOtherField's type dispatch. The label is the + // field's Userlabel (mdc.GetFieldLabel), the menu the autoCustom slice's + // mnuDataTree-Help (StandardParts.xml CmObject-Detail-Custom). + private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) + { + var fieldName = _mdc.GetFieldName(flid); + string rawEditor; + string wsSpec = null; + switch ((CellarPropertyType)_mdc.GetFieldType(flid)) + { + case CellarPropertyType.String: + rawEditor = "string"; + wsSpec = WritingSystemServices.GetMagicWsNameFromId(_mdc.GetFieldWs(flid)); + break; + case CellarPropertyType.MultiUnicode: + case CellarPropertyType.MultiString: + rawEditor = "multistring"; + wsSpec = WritingSystemServices.GetMagicWsNameFromId(_mdc.GetFieldWs(flid)); + break; + default: + // Resolved by CellarPropertyType in WalkOtherField, like autoCustom. + rawEditor = "autocustom"; + break; + } + + return new ViewNode($"{placeholder.StableId}/custom:{fieldName}", ViewNodeKind.Field, + _mdc.GetFieldLabel(flid), null, fieldName, rawEditor, EditorClassification.Known, + wsSpec, ViewVisibility.Always, ViewExpansion.NotApplicable, placeholder.Indented, + null, null, menuId: "mnuDataTree-Help"); + } + + // The three section-header construction sites (group header, summary slice, sequence + // banner) build the identical collapsible header row; one helper keeps them from drifting. + private void AddHeader(ViewNode node, ICmObject obj, int depth, string label) + { + Fields.Add(new LexicalEditRegionField(StableId(node, obj), label, node.Field, node.WritingSystem, + RegionFieldKind.Header, node.EditorClassification, node.AutomationId, node.LocalizationKey, + node.Routing, null, null, null, isEditable: false, indent: depth, + isCollapsible: true, isInitiallyExpanded: node.Expansion != ViewExpansion.Collapsed, + menuId: node.MenuId, hotlinksId: node.HotlinksId, objectHvo: obj.Hvo)); + } + + // Empty value in an always-visible row renders blank; an ifdata row hides instead. + private void AddRowUnlessHiddenWhenEmpty(ViewNode node, ICmObject obj, int depth) + { + if (!HideWhenEmpty(node)) + AddReadOnlyRow(node, obj, depth, string.Empty); + } + + private void WalkGroup(ViewNode node, ICmObject obj, int depth) + { + var headerIndex = Fields.Count; + var label = Localize(node.Label); + if (!string.IsNullOrEmpty(label)) + AddHeader(node, obj, depth, label); + + var childDepth = string.IsNullOrEmpty(label) ? depth : depth + 1; + foreach (var child in node.Children) + Walk(child, obj, childDepth); + + // An ifdata section whose children all hid renders nothing, including its header. + if (!string.IsNullOrEmpty(label) && Fields.Count == headerIndex + 1 && HideWhenEmpty(node)) + { + Fields.RemoveAt(headerIndex); + } + } + + private void WalkField(ViewNode node, ICmObject obj, int depth) + { + var fieldCountBeforeDispatch = Fields.Count; + var editor = (node.RawEditor ?? "").ToLowerInvariant(); + switch (editor) + { + case "multistring": + case "string": + WalkTextField(node, obj, depth); + break; + case "morphtypeatomicreference": + WalkMorphTypeChooser(node, obj, depth); + break; + case "summary": + // Summary slices are section header rows in legacy too. + AddHeader(node, obj, depth, Localize(node.Label) ?? node.Field); + break; + case "lit": + // Literal text row: the label IS the content. + AddReadOnlyRow(node, obj, depth, string.Empty); + break; + case "picture": + case "image": + WalkPictures(node, obj, depth); + break; + case "jtview": + // Embedded formatted view: render the object's summary text read-only (the + // full embedded-view replacement rides the table/IR work). + AddReadOnlyRow(node, obj, depth, obj.ShortName ?? string.Empty); + break; + case "command": + // Command slices render their button; execution arrives with the xCore + // command bridge (shell phase). + Fields.Add(new LexicalEditRegionField(StableId(node, obj), + Localize(node.Label) ?? node.Field, node.Field, node.WritingSystem, + RegionFieldKind.Command, node.EditorClassification, node.AutomationId, + node.LocalizationKey, node.Routing, null, null, null, + isEditable: false, indent: depth)); + break; + default: + WalkOtherField(node, obj, depth); + break; + } + + // Companion lane: a dynamically loaded custom slice (editor="Custom" class=...) + // keeps its legacy class/assembly identity, keyed by the StableId of the row the + // dispatch above produced for it — whether that was the explicit unsupported row or + // a best-effort read-only rendering (e.g. the Messages slice's field="Self" resolves + // to a reference-atomic flid and renders as read-only text). The host promotes + // designated classes (the Chorus Messages notes bar) to the WinForms companion strip + // and removes the row by this StableId. + if (!string.IsNullOrEmpty(node.CustomEditorClass) && Fields.Count > fieldCountBeforeDispatch) + { + var row = Fields[fieldCountBeforeDispatch]; + CustomEditorFields.Add(new ComposedCustomEditorField(row.StableId, + node.CustomEditorClass, node.CustomEditorAssembly, row.Label, obj.Hvo)); + } + + // Caller children under a slice (e.g. MorphType under the lexeme form) are fields of + // the same object, one level in. + foreach (var child in node.Children) + Walk(child, obj, depth + 1); + } + + private void WalkTextField(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + { + WalkUnsupported(node, obj, depth); + return; + } + + var type = (CellarPropertyType)_mdc.GetFieldType(flid); + var systems = ResolveWritingSystems(_cache, node.WritingSystem); + var values = new List(); + var anyData = false; + foreach (var ws in systems) + { + string text; + switch (type) + { + case CellarPropertyType.MultiUnicode: + case CellarPropertyType.MultiString: + text = _sda.get_MultiStringAlt(obj.Hvo, flid, ws.Handle)?.Text; + break; + case CellarPropertyType.String: + text = _sda.get_StringProp(obj.Hvo, flid)?.Text; + break; + case CellarPropertyType.Unicode: + text = _sda.get_UnicodeProp(obj.Hvo, flid); + break; + default: + WalkUnsupported(node, obj, depth); + return; + } + + anyData |= !string.IsNullOrEmpty(text); + // 11.15: the lexeme form's legacy bold/120% emphasis. + var fontSize = node.FontScalePercent > 0 ? 12.0 * node.FontScalePercent / 100.0 : 0; + values.Add(new RegionWsValue(ws.Abbreviation, text ?? string.Empty, ws.DefaultFontName, + fontSize, ws.RightToLeftScript, ws.Id, node.BoldEmphasis)); + if (type == CellarPropertyType.String || type == CellarPropertyType.Unicode) + break; // single-alternative property: one row + } + + if (!anyData && HideWhenEmpty(node)) + return; + + var stableId = StableId(node, obj); + var editable = type != CellarPropertyType.Unicode; + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, + node.WritingSystem, RegionFieldKind.Text, node.EditorClassification, node.AutomationId, + node.LocalizationKey, node.Routing, values, null, null, editable, depth, + menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, + objectHvo: obj.Hvo)); + + if (!editable) + return; + + var hvo = obj.Hvo; + // Edits key on the unique IETF tag (ws.Id): the user-editable Abbreviation can + // collide across writing systems, which both crashed composition (ToDictionary) + // and could misroute an edit to the wrong alternative. Unambiguous abbreviations + // stay accepted as aliases for callers addressing the row's gutter label. + var wsByKey = new Dictionary(StringComparer.Ordinal); + foreach (var ws in systems) + { + if (!string.IsNullOrEmpty(ws.Id)) + wsByKey[ws.Id] = ws.Handle; + } + foreach (var ws in systems) + { + if (!string.IsNullOrEmpty(ws.Abbreviation) && !wsByKey.ContainsKey(ws.Abbreviation) + && systems.Count(other => other.Abbreviation == ws.Abbreviation) == 1) + { + wsByKey.Add(ws.Abbreviation, ws.Handle); + } + } + TextSetters[stableId] = (wsKey, value) => + { + if (wsKey == null || !wsByKey.TryGetValue(wsKey, out var wsHandle)) + return false; + var tss = TsStringUtils.MakeString(value ?? string.Empty, wsHandle); + if (type == CellarPropertyType.String) + _sda.SetString(hvo, flid, tss); + else + _sda.SetMultiStringAlt(hvo, flid, wsHandle, tss); + return true; + }; + } + + private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) + { + if (!(obj is IMoForm form)) + { + WalkUnsupported(node, obj, depth); + return; + } + + if (_morphTypeOptions == null) + { + _morphTypeOptions = new List(); + var morphTypes = _cache.LangProject.LexDbOA?.MorphTypesOA; + if (morphTypes != null) + { + foreach (var possibility in morphTypes.ReallyReallyAllPossibilities.OfType() + .OrderBy(mt => mt.Name.BestAnalysisAlternative?.Text, StringComparer.Ordinal)) + { + _morphTypeOptions.Add(new RegionChoiceOption(possibility.Guid.ToString(), + possibility.Name.BestAnalysisAlternative?.Text ?? possibility.Guid.ToString())); + } + } + } + var options = _morphTypeOptions; + + var stableId = StableId(node, obj); + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, + node.WritingSystem, RegionFieldKind.Chooser, node.EditorClassification, node.AutomationId, + node.LocalizationKey, node.Routing, null, options, form.MorphTypeRA?.Guid.ToString(), + isEditable: true, indent: depth, + menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo)); + + OptionSetters[stableId] = optionKey => + { + if (!Guid.TryParse(optionKey, out var guid)) + return false; + var repository = _cache.ServiceLocator.GetInstance(); + if (!repository.TryGetObject(guid, out var morphType)) + return false; + // Legacy MorphTypeAtomicLauncher gates stem<->affix swaps behind a data-loss + // prompt AND a class conversion (MoStemAllomorph <-> MoAffixAllomorph). Assigning + // blindly would create a model-invalid combination (e.g. a stem allomorph with an + // affix morph type), so a boundary-crossing assignment is rejected until the + // class-conversion lane lands (review round 2). + if (TryClassifyMorphType(guid, out var toKind) + && (form is IMoStemAllomorph) != MorphTypeSwapLogic.IsStemType(toKind)) + { + return false; + } + form.MorphTypeRA = morphType; + return true; + }; + } + + // Maps the fixed MoMorphTypeTags GUIDs onto the seam's MorphTypeKind so the pure + // stem/affix boundary logic (extracted from MorphTypeAtomicLauncher) can classify them. + private static readonly IReadOnlyDictionary MorphTypeKindByGuid = + new Dictionary + { + { MoMorphTypeTags.kguidMorphRoot, MorphTypeKind.Root }, + { MoMorphTypeTags.kguidMorphStem, MorphTypeKind.Stem }, + { MoMorphTypeTags.kguidMorphBoundRoot, MorphTypeKind.BoundRoot }, + { MoMorphTypeTags.kguidMorphBoundStem, MorphTypeKind.BoundStem }, + { MoMorphTypeTags.kguidMorphParticle, MorphTypeKind.Particle }, + { MoMorphTypeTags.kguidMorphClitic, MorphTypeKind.Clitic }, + { MoMorphTypeTags.kguidMorphProclitic, MorphTypeKind.Proclitic }, + { MoMorphTypeTags.kguidMorphEnclitic, MorphTypeKind.Enclitic }, + { MoMorphTypeTags.kguidMorphPhrase, MorphTypeKind.Phrase }, + { MoMorphTypeTags.kguidMorphDiscontiguousPhrase, MorphTypeKind.DiscontiguousPhrase }, + { MoMorphTypeTags.kguidMorphPrefix, MorphTypeKind.Prefix }, + { MoMorphTypeTags.kguidMorphSuffix, MorphTypeKind.Suffix }, + { MoMorphTypeTags.kguidMorphInfix, MorphTypeKind.Infix }, + { MoMorphTypeTags.kguidMorphSimulfix, MorphTypeKind.Simulfix }, + { MoMorphTypeTags.kguidMorphSuprafix, MorphTypeKind.Suprafix }, + { MoMorphTypeTags.kguidMorphCircumfix, MorphTypeKind.Circumfix }, + { MoMorphTypeTags.kguidMorphPrefixingInterfix, MorphTypeKind.PrefixingInterfix }, + { MoMorphTypeTags.kguidMorphInfixingInterfix, MorphTypeKind.InfixingInterfix }, + { MoMorphTypeTags.kguidMorphSuffixingInterfix, MorphTypeKind.SuffixingInterfix } + }; + + private static bool TryClassifyMorphType(Guid guid, out MorphTypeKind kind) + => MorphTypeKindByGuid.TryGetValue(guid, out kind); + + // Viewing parity (11.x): every field type the legacy slices display has a rendering here: + // booleans as checkboxes (editable), integers editable, dates/gendates formatted, + // structured text as paragraph text, references as value rows; explicit unsupported rows + // for the rest. Empty fields show under "show hidden fields" exactly like legacy. + private void WalkOtherField(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid != 0) + { + var type = (CellarPropertyType)_mdc.GetFieldType(flid); + switch (type) + { + case CellarPropertyType.ReferenceAtomic: + { + var targetHvo = _sda.get_ObjectProp(obj.Hvo, flid); + if (targetHvo == 0) + { + AddRowUnlessHiddenWhenEmpty(node, obj, depth); + return; + } + + AddReadOnlyRow(node, obj, depth, ResolveShortName(targetHvo)); + return; + } + case CellarPropertyType.OwningAtomic: + { + var targetHvo = _sda.get_ObjectProp(obj.Hvo, flid); + if (targetHvo == 0) + { + AddRowUnlessHiddenWhenEmpty(node, obj, depth); + return; + } + + // Structured text renders its paragraph contents (StTextSlice's view). + if (_cache.ServiceLocator.ObjectRepository.GetObject(targetHvo) is IStText stText) + { + var paragraphs = stText.ParagraphsOS.OfType() + .Select(par => par.Contents?.Text ?? string.Empty); + var text = string.Join(Environment.NewLine, paragraphs); + if (string.IsNullOrWhiteSpace(text) && HideWhenEmpty(node)) + return; + AddReadOnlyRow(node, obj, depth, text); + return; + } + + AddReadOnlyRow(node, obj, depth, ResolveShortName(targetHvo)); + return; + } + case CellarPropertyType.ReferenceSequence: + case CellarPropertyType.ReferenceCollection: + { + var count = _sda.get_VecSize(obj.Hvo, flid); + if (count == 0) + { + AddRowUnlessHiddenWhenEmpty(node, obj, depth); + return; + } + + var names = new List(); + for (var i = 0; i < count; i++) + names.Add(ResolveShortName(_sda.get_VecItem(obj.Hvo, flid, i))); + AddReadOnlyRow(node, obj, depth, string.Join("; ", names)); + return; + } + case CellarPropertyType.Boolean: + { + var stableId = StableId(node, obj); + var isChecked = _sda.get_BooleanProp(obj.Hvo, flid); + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, + node.Field, node.WritingSystem, RegionFieldKind.Boolean, + node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, + null, null, isChecked ? "true" : "false", isEditable: true, indent: depth)); + var hvo = obj.Hvo; + OptionSetters[stableId] = key => + { + if (!bool.TryParse(key, out var value)) + return false; + _sda.SetBoolean(hvo, flid, value); + return true; + }; + return; + } + case CellarPropertyType.Integer: + { + var stableId = StableId(node, obj); + var current = _sda.get_IntProp(obj.Hvo, flid); + if (current == 0 && HideWhenEmpty(node)) + return; + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, + node.Field, node.WritingSystem, RegionFieldKind.Text, + node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, + new List { new RegionWsValue("", current.ToString()) }, + null, null, isEditable: true, indent: depth)); + var hvo = obj.Hvo; + TextSetters[stableId] = (ws, value) => + { + if (!int.TryParse(value, out var parsed)) + return false; + _sda.SetInt(hvo, flid, parsed); + return true; + }; + return; + } + case CellarPropertyType.Time: + { + var silTime = _sda.get_TimeProp(obj.Hvo, flid); + if (silTime == 0 && HideWhenEmpty(node)) + return; + // Legacy parity: DateSlice renders the full pattern ("f", CurrentUICulture) + // — the day name and all — with no UTC conversion. + var display = silTime == 0 + ? string.Empty + : SilTime.ConvertFromSilTime(silTime).ToString("f", CultureInfo.CurrentUICulture); + AddReadOnlyRow(node, obj, depth, display); + return; + } + case CellarPropertyType.GenDate: + { + string display; + try + { + var genDate = ((ISilDataAccessManaged)_sda).get_GenDateProp(obj.Hvo, flid); + display = genDate.IsEmpty ? string.Empty : genDate.ToLongString(); + } + catch (Exception) + { + display = string.Empty; + } + + if (string.IsNullOrEmpty(display) && HideWhenEmpty(node)) + return; + AddReadOnlyRow(node, obj, depth, display); + return; + } + } + } + + if (!HideWhenEmpty(node)) + WalkUnsupported(node, obj, depth); + } + + private void AddReadOnlyRow(ViewNode node, ICmObject obj, int depth, string display) + { + Fields.Add(new LexicalEditRegionField(StableId(node, obj), Localize(node.Label) ?? node.Field, + node.Field, node.WritingSystem, RegionFieldKind.Text, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, + new List { new RegionWsValue("", display ?? string.Empty) }, null, null, + isEditable: false, indent: depth, + menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo)); + } + + private void WalkUnsupported(ViewNode node, ICmObject obj, int depth) + { + Fields.Add(new LexicalEditRegionField(StableId(node, obj), Localize(node.Label) ?? node.Field, + node.Field, node.WritingSystem, RegionFieldKind.Unsupported, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, + isEditable: false, indent: depth)); + } + + // Viewing parity (11.6): picture fields render the actual image (caption + file path row); + // the view loads the bitmap when the file exists. + private void WalkPictures(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + { + WalkUnsupported(node, obj, depth); + return; + } + + var count = _sda.get_VecSize(obj.Hvo, flid); + if (count == 0) + { + if (!HideWhenEmpty(node)) + AddGhostPrompt(node, obj, depth); + return; + } + + for (var i = 0; i < count; i++) + { + if (!(_cache.ServiceLocator.ObjectRepository.GetObject(_sda.get_VecItem(obj.Hvo, flid, i)) + is ICmPicture picture)) + { + continue; + } + + var caption = picture.Caption?.BestVernacularAnalysisAlternative?.Text + ?? Localize(node.Label) ?? node.Field; + string path = null; + try + { + path = picture.PictureFileRA?.AbsoluteInternalPath; + } + catch (Exception) + { + } + + Fields.Add(new LexicalEditRegionField($"{StableId(node, obj)}/pic{i}", caption, + node.Field, null, RegionFieldKind.Image, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, + new List { new RegionWsValue("", path ?? string.Empty) }, + null, null, isEditable: false, indent: depth)); + } + } + + // Viewing parity (11.14) + 14.1: empty always-visible object/sequence fields show the + // legacy ghost add-prompt as a WATERMARK on an editable row — clicking in clears the + // prompt, and typing creates the missing object inside the fenced session (the legacy + // ghost-slice create-on-edit lane), routing the text into the layout's ghost field + // (ghost=/ghostWs=, e.g. the new allomorph's Form). + private void AddGhostPrompt(ViewNode node, ICmObject obj, int depth) + { + var label = Localize(node.GhostLabel) ?? Localize(node.Label) ?? node.Field; + if (string.IsNullOrEmpty(label)) + return; + var prompt = string.Format( + SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaStrings.GhostAddPromptFormat, label); + + var stableId = $"{StableId(node, obj)}/ghost"; + var ghost = ResolveGhostCreation(node, obj); + Fields.Add(new LexicalEditRegionField(stableId, label, node.Field, + node.WritingSystem, RegionFieldKind.Text, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, + new List { new RegionWsValue(ghost?.WsAbbrev ?? "", string.Empty, wsTag: ghost?.WsTag) }, + null, null, isEditable: ghost != null, indent: depth, + menuId: node.MenuId, hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, + ghostPrompt: prompt)); + + if (ghost != null) + TextSetters[stableId] = ghost.Setter; + } + + private sealed class GhostCreation + { + public string WsAbbrev; + public string WsTag; // unique IETF tag (ws.Id), the edit-routing identity + public Func Setter; + } + + // The create-on-edit half of the ghost lane: resolve the owning field's destination class + // (the layout's ghostClass when the model class is abstract; MoStemAllomorph for MoForm, + // matching legacy CreateAllomorph; Gloss-on-LexSense when no ghost field is authored) and + // build a setter that creates the object inside the open session — cancel rolls the + // creation back together with the text. + private GhostCreation ResolveGhostCreation(ViewNode node, ICmObject obj) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + return null; + + int insertOrd; + switch ((CellarPropertyType)_mdc.GetFieldType(flid)) + { + case CellarPropertyType.OwningAtomic: insertOrd = -2; break; + case CellarPropertyType.OwningCollection: insertOrd = -1; break; + case CellarPropertyType.OwningSequence: insertOrd = 0; break; + default: return null; // reference ghosts take a chooser, not a text creator + } + + int dstClass; + try + { + dstClass = _mdc.GetDstClsId(flid); + if (!string.IsNullOrEmpty(node.GhostClass)) + dstClass = _mdc.GetClassId(node.GhostClass); + else if (_mdc.GetAbstract(dstClass)) + { + if (dstClass != MoFormTags.kClassId) + return null; + dstClass = MoStemAllomorphTags.kClassId; // legacy CreateAllomorph default + } + } + catch (Exception) + { + return null; + } + + var ghostFieldName = node.GhostField + ?? (dstClass == LexSenseTags.kClassId ? "Gloss" : null); + var ghostFlid = 0; + if (!string.IsNullOrEmpty(ghostFieldName)) + { + try { ghostFlid = _mdc.GetFieldId2(dstClass, ghostFieldName, true); } + catch (Exception) { ghostFlid = 0; } + } + + var ws = ResolveGhostWs(node.GhostWs); + if (ws == null) + return null; + + var hvoOwner = obj.Hvo; + var ghostInitMethod = node.GhostInitMethod; + var createdHvo = 0; + return new GhostCreation + { + WsAbbrev = ws.Abbreviation, + WsTag = ws.Id, + Setter = (wsKey, value) => + { + // The closure outlives the edit session: a Cancel rolls MakeNewObject back, + // so a later edit through the same still-visible view must not write to the + // deleted hvo — verify it still exists and re-create when it does not + // (review round 2). + if (createdHvo != 0 + && !_cache.ServiceLocator.ObjectRepository.TryGetObject(createdHvo, out _)) + { + createdHvo = 0; + } + var created = false; + if (createdHvo == 0) + { + createdHvo = _sda.MakeNewObject(dstClass, hvoOwner, flid, insertOrd); + created = true; + } + if (ghostFlid != 0) + { + var tss = TsStringUtils.MakeString(value ?? string.Empty, ws.Handle); + if ((CellarPropertyType)_mdc.GetFieldType(ghostFlid) == CellarPropertyType.String) + _sda.SetString(createdHvo, ghostFlid, tss); + else + _sda.SetMultiStringAlt(createdHvo, ghostFlid, ws.Handle, tss); + } + // B2: invoke the layout's ghostInitMethod by reflection on the newly created + // object, after the typed text lands — exactly GhostStringSliceView. + // MakeRealObject's order (GhostStringSlice.cs:305-328); e.g. SetMorphTypeToRoot + // on a new lexeme-form allomorph, SetTypeToFreeTrans on a new translation. + if (created && !string.IsNullOrEmpty(ghostInitMethod)) + { + var createdObj = _cache.ServiceLocator.ObjectRepository.GetObject(createdHvo); + createdObj.GetType().GetMethod(ghostInitMethod)?.Invoke(createdObj, null); + } + return true; + } + }; + } + + private SIL.LCModel.Core.WritingSystems.CoreWritingSystemDefinition ResolveGhostWs(string ghostWs) + { + var systems = _cache.ServiceLocator.WritingSystems; + switch (ghostWs) + { + case "vernacular": + return systems.DefaultVernacularWritingSystem; + case "pronunciation": + return systems.DefaultPronunciationWritingSystem + ?? systems.DefaultVernacularWritingSystem; + default: + return systems.DefaultAnalysisWritingSystem; + } + } + + private void WalkObjectAtom(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + return; + var targetHvo = _sda.get_ObjectProp(obj.Hvo, flid); + if (targetHvo == 0) + { + // Ghost add-prompt for an empty always-visible object field (11.14). + if (!HideWhenEmpty(node)) + AddGhostPrompt(node, obj, depth); + return; + } + + var target = _cache.ServiceLocator.ObjectRepository.GetObject(targetHvo); + DescendInto(node, target, depth); + } + + private void WalkSequence(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + return; + var count = _sda.get_VecSize(obj.Hvo, flid); + if (count == 0) + { + if (!HideWhenEmpty(node)) + AddGhostPrompt(node, obj, depth); + return; + } + + var expanded = node.Expansion != ViewExpansion.Collapsed; + var isSenses = node.Field == "Senses"; + var sectionLabel = Localize(node.Label) ?? node.Field; + // Nested sense sequences (Senses on a sense) don't repeat the section banner; the + // numbered items carry it. + if (!(isSenses && obj is ILexSense)) + AddHeader(node, obj, depth, sectionLabel); + + for (var i = 0; i < count; i++) + { + var item = _cache.ServiceLocator.ObjectRepository.GetObject(_sda.get_VecItem(obj.Hvo, flid, i)); + string itemLabel; + if (isSenses && item is ILexSense sense) + { + // Legacy sense numbering: 1, 2, ... and 1.1 for subsenses, with the sense's + // summary text (ShortName = gloss) in the header line. Finding B: the number + // is the domain's own LexSenseOutline (the dictionary/bulk-edit outline), so + // the entry pane cannot diverge from the other surfaces. + itemLabel = ($"{sense.LexSenseOutline.Text} {item.ShortName}").TrimEnd(); + } + else + { + itemLabel = $"{sectionLabel} {i + 1}"; + } + + // 15.3: the item's own layout carries the slice menu (e.g. the sense's + // HeavySummary part ref binds mnuDataTree-Sense in LexSense.fwlayout) — the + // sequence node itself usually has none. + var itemBinding = ResolveItemMenuBinding(node, item); + Fields.Add(new LexicalEditRegionField($"{StableId(node, obj)}/item{i}", + itemLabel, node.Field, null, RegionFieldKind.Header, + EditorClassification.GroupingNone, null, null, SurfaceRouting.Inherit, + null, null, null, isEditable: false, indent: depth + 1, + isCollapsible: true, isInitiallyExpanded: expanded, + menuId: itemBinding.MenuId ?? node.MenuId, + hotlinksId: itemBinding.HotlinksId ?? node.HotlinksId, + objectHvo: item.Hvo)); + DescendInto(node, item, depth + 1); + } + } + + // 15.3: first root-level menu/hotlinks binding of the item's compiled layout (compile + // results are memoized per (class, layout), and the binding itself is memoized per + // compose state — finding A). + private (string MenuId, string HotlinksId) ResolveItemMenuBinding(ViewNode node, ICmObject item) + { + var layoutName = string.IsNullOrEmpty(node.TargetLayout) ? "Normal" : node.TargetLayout; + if (_itemMenuBindings.TryGetValue((item.ClassID, layoutName), out var cached)) + return cached; + + var compiled = CompileForObject(_cache, item, layoutName); + string menu = null, hotlinks = null; + if (compiled != null) + { + foreach (var root in compiled.Roots) + { + menu = menu ?? (string.IsNullOrEmpty(root.MenuId) ? null : root.MenuId); + hotlinks = hotlinks ?? (string.IsNullOrEmpty(root.HotlinksId) ? null : root.HotlinksId); + if (menu != null && hotlinks != null) + break; + } + } + + _itemMenuBindings[(item.ClassID, layoutName)] = (menu, hotlinks); + return (menu, hotlinks); + } + + private void DescendInto(ViewNode node, ICmObject target, int depth) + { + var layoutName = string.IsNullOrEmpty(node.TargetLayout) ? "Normal" : node.TargetLayout; + if (!_visited.Add((target.Hvo, layoutName))) + return; + + var compiled = CompileForObject(_cache, target, layoutName); + if (compiled != null && compiled.Roots.Count > 0) + { + foreach (var child in compiled.Roots) + Walk(child, target, depth + 1); + } + else + { + // No layout for the target: fall back to the caller-injected children, if any. + foreach (var child in node.Children) + Walk(child, target, depth + 1); + } + + _visited.Remove((target.Hvo, layoutName)); + } + + private int GetFlid(ICmObject obj, string fieldName) + { + if (string.IsNullOrEmpty(fieldName)) + return 0; + try + { + return _mdc.GetFieldId2(obj.ClassID, fieldName, true); + } + catch (Exception) + { + return 0; + } + } + + private string ResolveShortName(int hvo) + { + return _cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var target) + ? target.ShortName ?? string.Empty + : string.Empty; + } + + private static string StableId(ViewNode node, ICmObject obj) => $"{node.StableId}@{obj.Hvo}"; + + private static string Localize(string label) + => string.IsNullOrEmpty(label) ? label : StringTable.Table.LocalizeAttributeValue(label); + } + + // Layout ws= spec resolution (the composer's read AND write writing-system lists): the + // legacy pair — WritingSystemServices.GetMagicWsIdFromName then GetWritingSystemList — + // exactly as SliceFactory's multistring lane resolves it, so list membership and ordering + // ("analysis vernacular" vs "vernacular analysis") match legacy slices. Pronunciation + // specs ride the project's pronunciation list (kwsPronunciations; GetWritingSystemList has + // no kwsPronunciation branch), initialized on demand the same way legacy + // DefaultPronunciationWritingSystem initializes it. Empty/unknown specs take + // GetWritingSystemList's own analysis default — the legacy default for unmarked fields. + internal static IReadOnlyList ResolveWritingSystems(LcmCache cache, string spec) + { + var magicId = WritingSystemServices.GetMagicWsIdFromName(spec); + switch (magicId) + { + case WritingSystemServices.kwsPronunciation: + case WritingSystemServices.kwsFirstPronunciation: + case WritingSystemServices.kwsPronunciations: + WritingSystemServices.InitializePronunciationWritingSystems(cache); + magicId = WritingSystemServices.kwsPronunciations; + break; + } + + return WritingSystemServices.GetWritingSystemList(cache, magicId, forceIncludeEnglish: false); + } + + /// + /// Compiles the layout for an object's class, walking base classes the way legacy DataTree + /// does (e.g. MoStemAllomorph → MoForm) for both layout lookup and part resolution. + /// Memoized per (starting class, layout) for the lifetime of the loaded sources (finding A). + /// + internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName) + { + var sources = Sources.Value; + if (sources == null) + return null; + + return sources.CompiledModels.GetOrAdd((obj.ClassID, layoutName), + key => CompileForClass(cache, key.ClassId, key.LayoutName, sources)); + } + + private static ViewDefinitionModel CompileForClass(LcmCache cache, int classId, string layoutName, + CompilerSources sources) + { + Interlocked.Increment(ref s_snapshotCompileCount); + + var mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; + var baseClassMap = new Dictionary(StringComparer.Ordinal); + var clsid = classId; + XElement layout = null; + string className = null; + while (true) + { + className = mdc.GetClassName(clsid); + if (sources.LayoutIndex.TryGetValue((className, "detail", layoutName), out layout)) + break; + if (clsid == 0) + return null; + var baseId = mdc.GetBaseClsId(clsid); + if (baseId == clsid) + return null; + baseClassMap[className] = mdc.GetClassName(baseId); + clsid = baseId; + } + + // Part resolution may still need to climb from the layout's class upward. + var chain = clsid; + while (chain != 0) + { + var baseId = mdc.GetBaseClsId(chain); + if (baseId == chain || baseId == 0) + break; + baseClassMap[mdc.GetClassName(chain)] = mdc.GetClassName(baseId); + chain = baseId; + } + + var snapshot = new ViewDefinitionSourceSnapshot(className, "detail", layout.ToString(), + sources.PartsXml, baseClassMap); + return Compiler.Compile(snapshot); + } + + private static CompilerSources LoadSources() + { + try + { + // Finding D: the parts merge and layout glob live in the ONE shared loader + // (LayoutSourceLoader) that LexicalEditFirstSlice also uses. + var partsDirectory = FwDirectoryFinder.GetCodeSubDirectory(@"Language Explorer\Configuration\Parts"); + var partsXml = LayoutSourceLoader.LoadMergedPartsXml(partsDirectory); + if (partsXml == null) + return null; + + var layoutFiles = LayoutSourceLoader.LoadLayoutFiles(partsDirectory); + return new CompilerSources + { + PartsXml = partsXml, + LayoutIndex = LayoutSourceLoader.IndexLayouts(layoutFiles) + }; + } + catch (Exception) + { + return null; + } + } + } + + /// + /// The composed region's edit context: staging keyed by composed stable id (unique per object + /// occurrence, so each sense's Gloss binds its own sense), writes applied through the registered + /// LCModel setters inside the fenced session owned by + /// (finding C — one shared session lifecycle + required-lexeme validation). + /// + public sealed class ComposedRegionEditContext : RegionEditContextBase + { + private readonly IReadOnlyDictionary> _textSetters; + private readonly IReadOnlyDictionary> _optionSetters; + + public ComposedRegionEditContext( + LcmCache cache, + ILexEntry entry, + IReadOnlyDictionary> textSetters, + IReadOnlyDictionary> optionSetters) + : base(cache, entry) + { + _textSetters = textSetters; + _optionSetters = optionSetters; + } + + public override bool TrySetText(LexicalEditRegionField field, string ws, string value) + { + if (field == null || !_textSetters.TryGetValue(field.StableId, out var setter)) + return false; + EnsureOpen(); + return setter(ws, value); + } + + public override bool TrySetOption(LexicalEditRegionField field, string optionKey) + { + if (field == null || !_optionSetters.TryGetValue(field.StableId, out var setter)) + return false; + EnsureOpen(); + return setter(optionKey); + } + } +} diff --git a/Src/xWorks/FwDragDropData.cs b/Src/xWorks/FwDragDropData.cs new file mode 100644 index 0000000000..481530fa5b --- /dev/null +++ b/Src/xWorks/FwDragDropData.cs @@ -0,0 +1,58 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Windows.Forms; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The product side of the cross-surface drag-and-drop bridge (task 3.14): builds and reads the + /// shared OS data objects both WinForms (`DoDragDrop`/`AllowDrop`) and Avalonia (`DragDrop`) + /// surfaces exchange. Text drags reuse the clipboard seam's dual-lane payload + /// (: legacy `"TsString"` rich lane + + /// `UnicodeText`); object moves carry the framework-neutral + /// guid key plus a plain-text label for external drops. + /// Legacy in-surface reorder DnD (`SliceTreeNode`, `RecordBarTreeHandler`) stays surface-local + /// and untouched; specific drag interactions land with their editors (6.x/7.x). + /// + public static class FwDragDropData + { + /// Builds the data object for dragging a record (object move/link). + public static DataObject CreateRecordDataObject(ICmObject record) + { + if (record == null) + throw new ArgumentNullException(nameof(record)); + + var dataObject = new DataObject(); + dataObject.SetData(FwDragDropFormats.RecordKeyFormat, + new FwRecordKeyPayload(record.Guid).Serialize()); + dataObject.SetData(DataFormats.UnicodeText, record.ShortName ?? record.Guid.ToString()); + return dataObject; + } + + /// + /// Reads a dragged record key and resolves it in the given cache. False when the data is not + /// a FieldWorks record drag or the object does not exist in this project. + /// + public static bool TryGetRecord(IDataObject dataObject, LcmCache cache, out ICmObject record) + { + record = null; + if (dataObject == null || cache == null) + return false; + if (!(dataObject.GetData(FwDragDropFormats.RecordKeyFormat) is string serialized)) + return false; + if (!FwRecordKeyPayload.TryParse(serialized, out var payload)) + return false; + + return cache.ServiceLocator.ObjectRepository.TryGetObject(payload.ObjectGuid, out record); + } + + /// Builds the data object for dragging text (same dual-lane payload as copy/paste). + public static DataObject CreateTextDataObject(FwClipboardText payload) + => FwTsStringClipboard.CreateDataObject(payload); + } +} diff --git a/Src/xWorks/FwTsStringClipboard.cs b/Src/xWorks/FwTsStringClipboard.cs new file mode 100644 index 0000000000..dac18fdd97 --- /dev/null +++ b/Src/xWorks/FwTsStringClipboard.cs @@ -0,0 +1,117 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Text; +using System.Windows.Forms; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.RootSites; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The product cross-framework clipboard bridge (task 3.13): implements the LCModel-free + /// seam over the legacy OS clipboard contract that native-Views + /// surfaces already speak (EditingHelper.SetTsStringOnClipboard/GetTsStringFromClipboard): + /// the data format carrying a serialized + /// (TsString XML rep) plus an NFC-normalized UnicodeText + /// plain-text lane. Because both frameworks read and write the same formats, copy/paste + /// round-trips bidirectionally during coexistence; see for what + /// deliberately does not round-trip (ORC object references, external consumers, paragraph structure). + /// Goes through so tests can swap the OS clipboard for a stub. + /// + public sealed class FwTsStringClipboard : IFwClipboard + { + private const int MaxRetry = 3; + private const int RetrySleepMs = 200; + + private readonly ILgWritingSystemFactory _writingSystemFactory; + + public FwTsStringClipboard(ILgWritingSystemFactory writingSystemFactory) + { + _writingSystemFactory = writingSystemFactory ?? throw new System.ArgumentNullException(nameof(writingSystemFactory)); + } + + /// + public bool ContainsText() + { + return GetText() != null; + } + + /// + public FwClipboardText GetText() + { + var dataObject = ClipboardUtils.GetDataObject(); + if (dataObject == null) + return null; + + var wrapper = dataObject.GetData(TsStringWrapper.TsStringFormat) as TsStringWrapper; + var plain = dataObject.GetData(DataFormats.UnicodeText) as string; + if (wrapper == null && plain == null) + return null; + + return new FwClipboardText(plain ?? PlainTextFromXml(wrapper.Xml), wrapper?.Xml); + } + + /// + public void SetText(FwClipboardText payload) + { + ClipboardUtils.SetDataObject(CreateDataObject(payload), true, MaxRetry, RetrySleepMs); + } + + /// + /// Builds the dual-lane OS data object for a payload — the same entries legacy + /// EditingHelper.SetTsStringOnClipboard writes, so legacy surfaces consume the rich + /// lane exactly as if another Views surface had produced it. Shared by the clipboard seam + /// (3.13) and the drag-and-drop bridge (3.14), which carry identical text payloads. + /// + public static DataObject CreateDataObject(FwClipboardText payload) + { + if (payload == null) + throw new System.ArgumentNullException(nameof(payload)); + + var dataObject = new DataObject(); + if (!string.IsNullOrEmpty(payload.RichXml)) + { + var wrapper = TsStringWrapper.FromXml(payload.RichXml); + dataObject.SetData(TsStringWrapper.TsStringFormat, false, wrapper); + dataObject.SetData(DataFormats.Serializable, true, wrapper); + } + + dataObject.SetData(DataFormats.UnicodeText, true, + (payload.PlainText ?? string.Empty).Normalize(NormalizationForm.FormC)); + return dataObject; + } + + /// Builds the dual-lane payload from a TsString (rich + NFC plain text). + public FwClipboardText FromTsString(ITsString tsString) + { + if (tsString == null) + throw new System.ArgumentNullException(nameof(tsString)); + + var plain = (tsString.Text ?? string.Empty).Normalize(NormalizationForm.FormC); + return new FwClipboardText(plain, TsStringUtils.GetXmlRep(tsString, _writingSystemFactory, 0)); + } + + /// + /// Materializes the rich lane back into a TsString (writing systems resolved/added through the + /// factory), or null when the payload has no rich lane. + /// + public ITsString ToTsString(FwClipboardText payload) + { + if (payload?.RichXml == null) + return null; + + return TsStringSerializer.DeserializeTsStringFromXml(payload.RichXml, _writingSystemFactory); + } + + private string PlainTextFromXml(string xml) + { + var tss = TsStringSerializer.DeserializeTsStringFromXml(xml, _writingSystemFactory); + return (tss?.Text ?? string.Empty).Normalize(NormalizationForm.FormC); + } + } +} diff --git a/Src/xWorks/LcmRegionEditSession.cs b/Src/xWorks/LcmRegionEditSession.cs new file mode 100644 index 0000000000..dbb7518914 --- /dev/null +++ b/Src/xWorks/LcmRegionEditSession.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The product fenced edit session (tasks 6.8/6.10, per the `avalonia-edit-sessions` and + /// `avalonia-undo-redo` seam specs): one LCModel undo task spanning the user's edit, applied + /// directly to the domain. ends the task — every staged field edit becomes + /// ONE step on the single global LCModel action-handler stack legacy surfaces share, so Ctrl+Z + /// works across frameworks in both directions by construction. rolls the + /// whole task back to the depth captured at open (the same pattern legacy composition editing + /// uses, IbusRootSiteEventHandler). Idempotent: a second Commit/Cancel is a no-op. + /// + public sealed class LcmRegionEditSession : IEditSession + { + private readonly LcmCache _cache; + private readonly int _depth; + + public LcmRegionEditSession(LcmCache cache, string undoLabel, string redoLabel) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _depth = cache.ActionHandlerAccessor.CurrentDepth; + cache.DomainDataByFlid.BeginUndoTask(undoLabel, redoLabel); + IsOpen = true; + } + + /// + public bool IsOpen { get; private set; } + + /// + public void Commit() + { + if (!IsOpen) + return; + IsOpen = false; + if (TaskStillOpen) + _cache.DomainDataByFlid.EndUndoTask(); + } + + /// + public void Cancel() + { + if (!IsOpen) + return; + IsOpen = false; + if (TaskStillOpen) + _cache.ActionHandlerAccessor.Rollback(_depth); + } + + // RecordClerk.SaveOnChangeRecord (LT-16673) force-ends any open undo task on record change, + // closing this session's task underneath it. Ending or rolling back again would throw + // ("Rollback not supported in the current state"), so both closers check first. + private bool TaskStillOpen => _cache.ActionHandlerAccessor.CurrentDepth > _depth; + } +} diff --git a/Src/xWorks/LexicalEditPocMapper.cs b/Src/xWorks/LexicalEditPocMapper.cs deleted file mode 100644 index 849e7060ae..0000000000 --- a/Src/xWorks/LexicalEditPocMapper.cs +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using System.Collections.Generic; -using SIL.FieldWorks.Common.FwAvalonia.Poc; -using SIL.LCModel; -using SIL.LCModel.Core.Text; -using SIL.LCModel.Infrastructure; - -namespace SIL.FieldWorks.XWorks -{ - /// - /// Maps the current Lexical Edit record (`LexEntry`) into the detached DTO consumed by the - /// Avalonia POC surface. This is intentionally small and lossy: it only projects the three fields - /// the current POC renders (lexeme form, morph type, first-sense gloss). The legacy WinForms - /// DataTree remains the full-fidelity path; this mapper exists only for the feature-flagged - /// in-app spike. - /// - public static class LexicalEditPocMapper - { - public static PocEntryDto CreateDto(ICmObject obj, LcmCache cache) - { - var entry = obj as ILexEntry; - if (entry == null) - { - return null; - } - - return new PocEntryDto( - BuildLexemeForm(entry), - BuildMorphTypeOptions(), - GetMorphTypeKey(entry), - BuildFirstSenseGloss(entry, cache)); - } - - private static IList BuildLexemeForm(ILexEntry entry) - { - var values = new List(); - var lexemeText = entry.LexemeFormOA != null && entry.LexemeFormOA.Form != null - ? entry.LexemeFormOA.Form.VernacularDefaultWritingSystem.Text - : string.Empty; - if (string.IsNullOrEmpty(lexemeText)) - { - lexemeText = entry.CitationForm.VernacularDefaultWritingSystem.Text; - } - - values.Add(new WsAlternative("vern", lexemeText)); - return values; - } - - private static IList BuildFirstSenseGloss(ILexEntry entry, LcmCache cache) - { - var values = new List(); - if (entry.SensesOS.Count > 0) - { - var gloss = entry.SensesOS[0].Gloss.AnalysisDefaultWritingSystem.Text; - values.Add(new WsAlternative("anal", gloss, "Times New Roman")); - } - else - { - values.Add(new WsAlternative("anal", string.Empty, "Times New Roman")); - } - - return values; - } - - private static IList BuildMorphTypeOptions() - { - return new List - { - new MorphTypeOption("stem", "stem"), - new MorphTypeOption("root", "root"), - new MorphTypeOption("prefix", "prefix"), - new MorphTypeOption("suffix", "suffix") - }; - } - - private static string GetMorphTypeKey(ILexEntry entry) - { - var type = entry.LexemeFormOA != null ? entry.LexemeFormOA.MorphTypeRA : null; - if (type == null) - { - return "stem"; - } - - if (type.Guid == MoMorphTypeTags.kguidMorphPrefix) - { - return "prefix"; - } - if (type.Guid == MoMorphTypeTags.kguidMorphSuffix) - { - return "suffix"; - } - if (type.Guid == MoMorphTypeTags.kguidMorphRoot || type.Guid == MoMorphTypeTags.kguidMorphBoundRoot) - { - return "root"; - } - - return "stem"; - } - } -} diff --git a/Src/xWorks/LexicalEditRegionBuilder.cs b/Src/xWorks/LexicalEditRegionBuilder.cs index 0c1a86b020..cf64256b58 100644 --- a/Src/xWorks/LexicalEditRegionBuilder.cs +++ b/Src/xWorks/LexicalEditRegionBuilder.cs @@ -6,103 +6,183 @@ using System.Collections.Generic; using SIL.FieldWorks.Common.FwAvalonia.Region; using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; namespace SIL.FieldWorks.XWorks { /// /// Builds the product Lexical Edit region model from the typed view definition plus live LCModel - /// values (task 4.8). This is the typed-definition-backed replacement for - /// : structure is expressed as a - /// (the same IR vocabulary the XML importer produces), and this type only supplies values via - /// . The first-slice definition is authored here for the LexEntry - /// identity fields; the next step is to compile it from the live layout inventory so the region scales - /// to the full layout. Values are read on the UI thread; write-back goes through the LCModel edit - /// session (tasks 6.x), not this builder. + /// values (tasks 4.8/4.10). Structure comes from , which compiles + /// the shipped layout inventory through ViewDefinitionCompiler; the authored definition + /// remains only as an explicit, diagnosed fallback. This type supplies values via + /// : text from the entry, and morph-type chooser options sourced + /// from the project's LCModel morph-type possibility list (no hardcoded option set). Values are read + /// on the UI thread; write-back goes through the LCModel edit session (tasks 6.x), not this builder. /// public sealed class LexicalEditRegionBuilder : IRegionValueProvider { - private const string LexemeFormField = "LexemeForm"; + // Field names as they appear in the compiled shipped layouts (MoForm AsLexemeForm slice, + // MoForm MorphTypeBasic slice, LexSense GlossAllA slice). + private const string LexemeFormField = "Form"; private const string GlossField = "Gloss"; private const string MorphTypeField = "MorphType"; + private static readonly Lazy FirstSliceDefinition = + new Lazy(CompileOrFallback); + private readonly ILexEntry _entry; + private readonly LcmCache _cache; - private LexicalEditRegionBuilder(ILexEntry entry) + private LexicalEditRegionBuilder(ILexEntry entry, LcmCache cache) { _entry = entry; + _cache = cache; } /// /// Builds a region model for the current record, or null if it is not a - /// (the caller then shows an explicit unsupported state). is reserved for - /// the writing-system/font service that will replace the placeholder ws abbreviations. + /// (the caller then shows an explicit unsupported state). /// public static LexicalEditRegionModel Build(ICmObject obj, LcmCache cache) { if (!(obj is ILexEntry entry)) return null; - var definition = BuildFirstSliceDefinition(); - var provider = new LexicalEditRegionBuilder(entry); - return LexicalEditRegionMapper.FromViewDefinition(definition, provider); + var provider = new LexicalEditRegionBuilder(entry, cache); + return LexicalEditRegionMapper.FromViewDefinition(FirstSliceDefinition.Value, provider); } /// - /// The typed view definition for the LexEntry identity first slice, expressed in the IR vocabulary - /// with stable ids, writing-system metadata, accessibility ids, and product routing. Authored for - /// now; replace with a live layout compile (ViewDefinitionCompiler) as the region grows. + /// Task 4.10: compile the first-slice definition from the live shipped layout inventory. The + /// authored definition (which carries an `authored-fallback` diagnostic) is used only when the + /// layout directory is unavailable or a shipped layout no longer yields the expected nodes. /// - internal static ViewDefinitionModel BuildFirstSliceDefinition() + private static ViewDefinitionModel CompileOrFallback() { - var roots = new List + string partsDirectory = null; + try + { + partsDirectory = FwDirectoryFinder.GetCodeSubDirectory(@"Language Explorer\Configuration\Parts"); + } + catch (ApplicationException) { - Leaf("LexEntry/identity/#0", "Lexeme Form", LexemeFormField, "multistring", "vernacular", "LexemeFormEditor"), - Leaf("LexEntry/identity/#1", "Morph Type", MorphTypeField, "morphtypeatomicreference", null, "MorphTypeChooser"), - Leaf("LexEntry/identity/#2", "Gloss", GlossField, "multistring", "analysis", "SenseGlossEditor") - }; + // No FieldWorks code directory in this environment (bare harness); use the fallback. + } - return new ViewDefinitionModel("LexEntry", "identity", "detail", roots, Array.Empty()); + return LexicalEditFirstSlice.CompileFromLayoutDirectory(partsDirectory) + ?? LexicalEditFirstSlice.AuthoredFallback(); } - private static ViewNode Leaf(string stableId, string label, string field, string editor, string ws, string automationId) - => new ViewNode(stableId, ViewNodeKind.Field, label, null, field, editor, - EditorClassification.Known, ws, ViewVisibility.Always, ViewExpansion.NotApplicable, false, null, null, - localizationKey: null, automationId: automationId, routing: SurfaceRouting.Product); - /// public IReadOnlyList GetValues(ViewNode fieldNode) { switch (fieldNode.Field) { case LexemeFormField: - return new List { new RegionWsValue("vern", GetLexemeFormText()) }; + return GetLexemeFormValues(); case GlossField: - return new List { new RegionWsValue("anal", GetFirstSenseGloss()) }; + return GetGlossValues(); default: return new List(); } } + // Tasks 6.2/6.13 (multi-WS read path): one row per *current* writing system — the same + // "all vernacular"/"all analysis" semantics the compiled slice definitions carry — rendered + // with the project's per-WS default font so both surfaces show the same record consistently. + private IReadOnlyList GetLexemeFormValues() + { + var values = new List(); + foreach (var ws in _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems) + { + var text = _entry.LexemeFormOA?.Form?.get_String(ws.Handle)?.Text; + if (string.IsNullOrEmpty(text) && ws.Handle == _cache.DefaultVernWs) + text = _entry.CitationForm.get_String(ws.Handle)?.Text; // legacy fallback, default ws only + values.Add(new RegionWsValue(ws.Abbreviation, text ?? string.Empty, ws.DefaultFontName, 0, + ws.RightToLeftScript, ws.Id)); + } + + return values; + } + + private IReadOnlyList GetGlossValues() + { + var values = new List(); + if (_entry.SensesOS.Count == 0) + return values; + + var gloss = _entry.SensesOS[0].Gloss; + foreach (var ws in _cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems) + values.Add(new RegionWsValue(ws.Abbreviation, gloss.get_String(ws.Handle)?.Text ?? string.Empty, + ws.DefaultFontName, 0, ws.RightToLeftScript, ws.Id)); + + return values; + } + + /// + /// Activates the writing system's configured keyboard (Keyman/Windows IME) when its editor + /// row gains focus on the Avalonia surface — the behavior legacy slices get from + /// EditingHelper.SetKeyboardForWs (task 6.2). Unknown tags fall back to the default keyboard. + /// + public static void ActivateKeyboardForWritingSystem(LcmCache cache, string wsTag) + { + try + { + foreach (var ws in cache.ServiceLocator.WritingSystems.AllWritingSystems) + { + if (ws.Id == wsTag) + { + ws.LocalKeyboard?.Activate(); + return; + } + } + + SIL.Keyboarding.Keyboard.Controller.ActivateDefaultKeyboard(); + } + catch (Exception) + { + // Keyboard switching must never take down editing; legacy swallows comparable failures. + } + } + /// public IReadOnlyList GetOptions(ViewNode fieldNode) { + var options = new List(); if (fieldNode.Field != MorphTypeField) - return new List(); + return options; - return new List - { - new RegionChoiceOption("stem", "stem"), - new RegionChoiceOption("root", "root"), - new RegionChoiceOption("prefix", "prefix"), - new RegionChoiceOption("suffix", "suffix") - }; + // Task 4.10: chooser options come from the project's morph-type possibility list, keyed by + // guid, so every project-defined morph type (phrase, clitic, infix, ...) is offered instead + // of a hardcoded subset. + var morphTypes = _cache.LangProject.LexDbOA?.MorphTypesOA; + if (morphTypes == null) + return options; + + AddPossibilities(morphTypes.PossibilitiesOS, options); + return options; } /// public string GetSelectedOptionKey(ViewNode fieldNode) { - return fieldNode.Field == MorphTypeField ? GetMorphTypeKey() : null; + if (fieldNode.Field != MorphTypeField) + return null; + + return _entry.LexemeFormOA?.MorphTypeRA?.Guid.ToString(); + } + + private static void AddPossibilities(IEnumerable possibilities, List options) + { + foreach (var possibility in possibilities) + { + var name = possibility.Name.BestAnalysisAlternative?.Text; + if (string.IsNullOrEmpty(name)) + name = possibility.Name.BestVernacularAlternative?.Text ?? possibility.Guid.ToString(); + options.Add(new RegionChoiceOption(possibility.Guid.ToString(), name)); + AddPossibilities(possibility.SubPossibilitiesOS, options); + } } private string GetLexemeFormText() @@ -121,19 +201,5 @@ private string GetFirstSenseGloss() return string.Empty; return _entry.SensesOS[0].Gloss.AnalysisDefaultWritingSystem.Text ?? string.Empty; } - - private string GetMorphTypeKey() - { - var type = _entry.LexemeFormOA?.MorphTypeRA; - if (type == null) - return "stem"; - if (type.Guid == MoMorphTypeTags.kguidMorphPrefix) - return "prefix"; - if (type.Guid == MoMorphTypeTags.kguidMorphSuffix) - return "suffix"; - if (type.Guid == MoMorphTypeTags.kguidMorphRoot || type.Guid == MoMorphTypeTags.kguidMorphBoundRoot) - return "root"; - return "stem"; - } } } diff --git a/Src/xWorks/LexicalEditRegionEditContext.cs b/Src/xWorks/LexicalEditRegionEditContext.cs new file mode 100644 index 0000000000..20348829bf --- /dev/null +++ b/Src/xWorks/LexicalEditRegionEditContext.cs @@ -0,0 +1,115 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.LCModel.Core.Text; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The product for the LexEntry first slice (tasks 6.8/6.10): + /// stages writes directly into LCModel inside a lazily opened + /// (fenced undo task, owned by ), validates required fields, + /// and commits/cancels the fence. Field names match the compiled first-slice definition + /// (`Form`/`Gloss`/`MorphType`). Detached DTO editing remains preview-only; this context is the + /// real domain write path. + /// + public sealed class LexicalEditRegionEditContext : RegionEditContextBase + { + public LexicalEditRegionEditContext(ILexEntry entry, LcmCache cache) + : base(cache, entry) + { + } + + /// + public override bool TrySetText(LexicalEditRegionField regionField, string ws, string value) + { + switch (regionField?.Field) + { + case "Form": + { + if (!TryResolveWsHandle(ws, vernacular: true, out var wsHandle)) + return false; + EnsureOpen(); + var text = TsStringUtils.MakeString(value ?? string.Empty, wsHandle); + // Mirror the read fallback: entries without a lexeme form object edit the citation form. + if (Entry.LexemeFormOA != null) + Entry.LexemeFormOA.Form.set_String(wsHandle, text); + else + Entry.CitationForm.set_String(wsHandle, text); + return true; + } + case "Gloss": + { + if (Entry.SensesOS.Count == 0) + return false; + if (!TryResolveWsHandle(ws, vernacular: false, out var wsHandle)) + return false; + EnsureOpen(); + Entry.SensesOS[0].Gloss.set_String(wsHandle, TsStringUtils.MakeString(value ?? string.Empty, wsHandle)); + return true; + } + default: + return false; + } + } + + // Tasks 6.2/6.13 (multi-WS write path): each per-WS row writes its own alternative. The row + // addresses its writing system by the unique IETF tag (RegionWsValue.WsTag/ws.Id) first; + // the user-editable Abbreviation (which can collide) and the legacy "vern"/"anal" aliases + // from the fixed first-slice definition are accepted as fallbacks. Any OTHER unknown key is + // rejected (review round 2) — a silent write to the DEFAULT alternative is worse than no + // write, and it matches ComposedRegionEditContext, which also rejects unknown keys. + private bool TryResolveWsHandle(string ws, bool vernacular, out int wsHandle) + { + var container = Cache.ServiceLocator.WritingSystems; + var systems = vernacular ? container.CurrentVernacularWritingSystems : container.CurrentAnalysisWritingSystems; + foreach (var def in systems) + { + if (def.Id == ws) + { + wsHandle = def.Handle; + return true; + } + } + + foreach (var def in systems) + { + if (def.Abbreviation == ws) + { + wsHandle = def.Handle; + return true; + } + } + + if (ws == "vern" || ws == "anal") + { + wsHandle = vernacular ? Cache.DefaultVernWs : Cache.DefaultAnalWs; + return true; + } + + wsHandle = 0; + return false; + } + + /// + public override bool TrySetOption(LexicalEditRegionField regionField, string optionKey) + { + if (regionField?.Field != "MorphType" || Entry.LexemeFormOA == null) + return false; + if (!Guid.TryParse(optionKey, out var guid)) + return false; + + var repository = Cache.ServiceLocator.GetInstance(); + if (!repository.TryGetObject(guid, out var morphType)) + return false; + + EnsureOpen(); + Entry.LexemeFormOA.MorphTypeRA = morphType; + return true; + } + } +} diff --git a/Src/xWorks/RecordClerkNavigationContext.cs b/Src/xWorks/RecordClerkNavigationContext.cs new file mode 100644 index 0000000000..555cb6cbe1 --- /dev/null +++ b/Src/xWorks/RecordClerkNavigationContext.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The product selection bridge (task 3.12): implements over + /// a real xCore so an Avalonia surface follows and publishes the same + /// "current record" bus the legacy surfaces use. + /// + /// Follow direction: broadcasts record changes through the mediator as + /// RecordNavigation messages, which only reach colleagues through a sponsoring content + /// control. The owning host () therefore feeds this bridge from its + /// OnRecordNavigation handler via — the event is + /// driven by the real broadcast path, not by polling or a parallel channel. + /// + /// Publish direction: routes through the clerk's real + /// OnJumpToRecord handler, and the movement methods through OnNextRecord/ + /// OnPreviousRecord, so a selection made on an Avalonia surface broadcasts to every legacy + /// surface exactly as a legacy navigation would. + /// + public sealed class RecordClerkNavigationContext : IRecordNavigationContext + { + private readonly RecordClerk _clerk; + + public RecordClerkNavigationContext(RecordClerk clerk) + { + _clerk = clerk ?? throw new ArgumentNullException(nameof(clerk)); + } + + /// + public object CurrentRecord => _clerk.CurrentObject; + + /// + public event EventHandler CurrentRecordChanged; + + /// + public bool MoveNext() + { + return _clerk.OnNextRecord(null); + } + + /// + public bool MovePrevious() + { + return _clerk.OnPreviousRecord(null); + } + + /// + public bool PublishSelection(object recordKey) + { + switch (recordKey) + { + case int hvo: + return _clerk.OnJumpToRecord(hvo); + case ICmObject obj: + return _clerk.OnJumpToRecord(obj.Hvo); + default: + return false; + } + } + + /// + /// Called by the sponsoring host when the real mediator broadcast delivers a record navigation + /// for this bridge's clerk (follow direction). + /// + internal void NotifyCurrentRecordChanged() + { + CurrentRecordChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index c1eca16f25..d02a9f70af 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -6,10 +6,13 @@ using System.ComponentModel; using System.Diagnostics; using System.Drawing.Printing; +using System.Linq; using System.Windows.Forms; using System.Xml; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Poc; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.Seams; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.LCModel; using XCore; @@ -67,6 +70,21 @@ public class RecordEditView : RecordView, IVwNotifyChange, IFocusablePanePortion private readonly LexicalEditSurfaceSelectionService m_surfaceSelectionService = new LexicalEditSurfaceSelectionService(); private PocWinFormsHostControl m_avaloniaEntryForm; private bool m_legacySurfaceInitialized; + private RecordClerkNavigationContext m_recordNavigationContext; + // Owns the fenced edit context; swapping/clearing through it cancels any open session so an + // open undo task is never orphaned (an orphan makes the shutdown Save throw "Commit at wrong place"). + private readonly RegionEditContextHolder m_regionEditContext = new RegionEditContextHolder(); + private AvaloniaRegionRefreshController m_avaloniaRefreshController; + // Settle-on-deactivate hook (review round 2): the undo guard is per-stack and cannot reach + // other windows' undo stacks, so settle when this view's top-level window loses activation. + private EventHandler m_settleOnDeactivate; + private Form m_guardedForm; + // Hybrid companion lane: the real WinForms slices (today the Chorus Messages notes bar) + // promoted out of the Avalonia model into the host's companion strip, plus their editor + // controls (reparented into the strip, so the slice's Dispose no longer reaches them). + // Recreated per shown record; torn down on record change/clear/dispose. + private readonly List m_companionSlices = new List(); + private readonly List m_companionControls = new List(); //// //// used to associate menu commands with the slice that sent them @@ -166,8 +184,28 @@ protected override void Dispose(bool disposing) if (m_dataEntryForm != null) { m_dataEntryForm.CurrentSliceChanged -= m_dataEntryForm_CurrentSliceChanged; - m_dataEntryForm.Dispose(); + if (m_legacySurfaceInitialized) + m_dataEntryForm.Dispose(); + else if (m_panel != null && m_panel.Controls.Contains(m_dataEntryForm)) + m_panel.Controls.Remove(m_dataEntryForm); } + // Teardown order matters: stop the event/notification plumbing FIRST so the + // settle's commit/rollback PropChanged cannot re-enter a dying view, then settle + // (auto-save 14.4 extends to teardown: a valid pending edit commits, invalid rolls + // back), and only then drop the context. + if (m_avaloniaEntryForm != null) + m_avaloniaEntryForm.RegionEditCompleted -= OnAvaloniaRegionEditCompleted; + if (m_guardedForm != null && m_settleOnDeactivate != null) + { + m_guardedForm.Deactivate -= m_settleOnDeactivate; + m_guardedForm = null; + m_settleOnDeactivate = null; + } + m_regionEditContext.DetachUndoGuard(); + m_avaloniaRefreshController?.Dispose(); + m_regionEditContext.Settle(); + m_regionEditContext.Clear(); + TearDownCompanionSlices(); m_avaloniaEntryForm?.Dispose(); m_menuHandler?.Dispose(); if (!string.IsNullOrEmpty(m_titleField)) @@ -175,6 +213,7 @@ protected override void Dispose(bool disposing) } m_dataEntryForm = null; m_avaloniaEntryForm = null; + m_avaloniaRefreshController = null; base.Dispose(disposing); } @@ -207,6 +246,10 @@ public override bool OnRecordNavigation(object argument) { window.ResumeIdleProcessing(); } + + // Selection bridge (task 3.12): the real mediator broadcast delivered a record navigation + // for this host's clerk, so let bridge subscribers (the Avalonia surface) follow it. + m_recordNavigationContext?.NotifyCurrentRecordChanged(); return true; //we handled this. } @@ -235,8 +278,16 @@ public override bool PrepareToGoAway() { CheckDisposed(); - if (!ShouldUseAvaloniaLexicalEdit && m_dataEntryForm != null) + if (ShouldUseAvaloniaLexicalEdit) + { + // Auto-save (14.4): leaving the tool/area settles any open fenced session the same + // way legacy slices save as the user moves on. + m_regionEditContext.Settle(); + } + else if (m_dataEntryForm != null) + { m_dataEntryForm.PrepareToGoAway(); + } return base.PrepareToGoAway(); } @@ -256,6 +307,15 @@ public void OnPropertyChanged(string name) { CheckDisposed(); + // Viewing parity (11.x): the View → Show Hidden Fields toggle re-resolves the Avalonia + // region just like it rebuilds the legacy DataTree. + if (name != null && name.StartsWith("ShowHiddenFields-", StringComparison.Ordinal)) + { + if (ShouldUseAvaloniaLexicalEdit) + RefreshAvaloniaRegion(); + return; + } + if (name != LexicalEditSurfaceResolver.UIModePropertyName) return; @@ -263,6 +323,11 @@ public void OnPropertyChanged(string name) if (newSurface == m_lexicalEditSurface) return; + // Settle any open fenced session BEFORE flipping the surface: ShowRecord's settle (and + // ShowRecordOnIdle's) is gated on ShouldUseAvaloniaLexicalEdit, which is already false + // once m_lexicalEditSurface changes — without this, flipping UIMode mid-edit would let + // Clerk.SaveOnChangeRecord force-commit invalid staged state (review round 2). + m_regionEditContext.Settle(); m_lexicalEditSurface = newSurface; ShowRecord(new RecordNavigationInfo(Clerk, Clerk.SuppressSaveOnChangeRecord, false, true)); } @@ -337,6 +402,11 @@ bool ShowRecordOnIdle(object parameter) Debug.Assert(m_dataEntryForm != null); var rni = (RecordNavigationInfo) parameter; + // Auto-save (14.4) must run BEFORE the clerk's save-on-change-record: + // RecordClerk.SaveOnChangeRecord force-EndUndoTasks any open undo task wholesale + // (LT-16673), which would commit invalid staged state past the validation gate. + if (ShouldUseAvaloniaLexicalEdit) + m_regionEditContext.Settle(); bool oldSuppressSaveOnChangeRecord = Clerk.SuppressSaveOnChangeRecord; Clerk.SuppressSaveOnChangeRecord = rni.SuppressSaveOnChangeRecord; PrepCacheForNewRecord(); @@ -347,13 +417,15 @@ bool ShowRecordOnIdle(object parameter) if (ShouldUseAvaloniaLexicalEdit) { // Active-host contract (task 3.10): do not touch the legacy DataTree while Avalonia is active. - if (m_avaloniaEntryForm == null) - EnsureAvaloniaSurfaceInitialized(); - m_avaloniaEntryForm.Hide(); + // The record may be gone (deleted elsewhere); cancel rather than orphan the session. + m_regionEditContext.Clear(); + EnsureAvaloniaSurfaceActive(); + TearDownCompanionSlices(); m_avaloniaEntryForm.Clear(); } else { + EnsureLegacySurfaceVisible(); m_dataEntryForm.Hide(); m_dataEntryForm.Reset(); // in case user deleted the object it was based upon. } @@ -365,21 +437,12 @@ bool ShowRecordOnIdle(object parameter) // or drive the legacy DataTree. Only the active surface is created and shown. if (ShouldUseAvaloniaLexicalEdit) { - if (m_avaloniaEntryForm == null) - EnsureAvaloniaSurfaceInitialized(); + EnsureAvaloniaSurfaceActive(); } else { - if (!m_legacySurfaceInitialized) - { - var localPersistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); - if (localPersistContext != "") - localPersistContext = m_vectorName + "." + localPersistContext + ".DataTree"; - else - localPersistContext = m_vectorName + ".DataTree"; - EnsureLegacySurfaceInitialized(localPersistContext); - } - m_dataEntryForm.Show(); + EnsureLegacySurfaceInitialized(); + EnsureLegacySurfaceVisible(); } // Enhance: Maybe do something here to allow changing the templates without the starting the application. @@ -394,13 +457,11 @@ bool ShowRecordOnIdle(object parameter) if (ShouldUseAvaloniaLexicalEdit && m_avaloniaEntryForm != null) { - // Task 4.8: the product route builds a typed-definition-backed region model, not the - // lossy POC DTO (which is now preview-host only). - var region = LexicalEditRegionBuilder.Build(obj, Cache); - if (region == null) - m_avaloniaEntryForm.ShowMessage("Avalonia lexical edit is currently available only for LexEntry records."); - else - m_avaloniaEntryForm.ShowRegion(region); + // Sections 6/7: the product route composes the COMPLETE entry view from the live + // compiled layouts (full cross-object walk, headers, ifdata) and falls back to the + // fixed first slice only if composition fails; both are editable through the fenced + // LCModel session (6.8/6.10) with refresh propagation (3.15). + ShowAvaloniaEntry(obj); } else { @@ -430,6 +491,21 @@ private bool ShouldSuppressFocusChange(RecordNavigationInfo rni) return !IsFocusedPane || rni.SuppressFocusChange; } + /// + /// The bidirectional selection bridge for this host's clerk (task 3.12). Created on first use so + /// the clerk is initialized. Surfaces (including the Avalonia host) follow the current-record bus + /// through its event and publish their own selection back through it. + /// + internal IRecordNavigationContext RecordNavigationContext + { + get + { + if (m_recordNavigationContext == null && Clerk != null) + m_recordNavigationContext = new RecordClerkNavigationContext(Clerk); + return m_recordNavigationContext; + } + } + private LexicalEditSurface ResolveConfiguredLexicalEditSurface() { // Task 3.9: route the per-host decision through the explicit selection service rather than @@ -444,12 +520,30 @@ private LexicalEditSurface ResolveConfiguredLexicalEditSurface() return m_surfaceSelectionService.Decide(uiMode, toolName).Surface; } - private void EnsureLegacySurfaceInitialized(string persistContext) + // This plus the name of the vector gives a unique context for the DataTree control + // parameters (e.g. "lexicon.basicEdit.DataTree"). + private string DataTreePersistContext + { + get + { + var persistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); + return string.IsNullOrEmpty(persistContext) + ? m_vectorName + ".DataTree" + : m_vectorName + "." + persistContext + ".DataTree"; + } + } + + private void EnsureLegacySurfaceInitialized() { if (m_legacySurfaceInitialized) return; - m_dataEntryForm.PersistenceProvder = new PersistenceProvider(m_mediator, m_propertyTable, persistContext); + m_dataEntryForm.PersistenceProvder = new PersistenceProvider(m_mediator, m_propertyTable, DataTreePersistContext); + + // In Avalonia mode Init skips the stylesheet (the legacy tree is inactive); the lazy + // command-routing adapter still needs it before ShowObject builds slices. + if (m_dataEntryForm.StyleSheet == null) + m_dataEntryForm.StyleSheet = FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable); SetupSliceFilter(); m_dataEntryForm.Dock = DockStyle.Fill; @@ -465,8 +559,7 @@ private void EnsureLegacySurfaceInitialized(string persistContext) m_menuHandler.Init(m_mediator, m_propertyTable, m_configurationParameters); m_dataEntryForm.SetContextMenuHandler(m_menuHandler.ShowSliceContextMenu); - if (!m_panel.Controls.Contains(m_dataEntryForm)) - m_panel.Controls.Add(m_dataEntryForm); + AttachLegacySurfaceToPanel(); m_legacySurfaceInitialized = true; } @@ -478,10 +571,420 @@ private void EnsureAvaloniaSurfaceInitialized() m_avaloniaEntryForm = (PocWinFormsHostControl)m_lexicalEditSurfaceFactory.Create(LexicalEditSurface.Avalonia); m_avaloniaEntryForm.Dock = DockStyle.Fill; + m_avaloniaEntryForm.RegionEditCompleted += OnAvaloniaRegionEditCompleted; if (!m_panel.Controls.Contains(m_avaloniaEntryForm)) m_panel.Controls.Add(m_avaloniaEntryForm); } + /// + /// Task 3.15: subscribe the Avalonia surface to the real PropChanged bus so external edits to + /// the displayed entry (legacy surfaces, refresh-driven reloads) re-resolve the region. + /// Refreshes are held while this surface's own edit session is open and delivered on completion. + /// + private void EnsureAvaloniaRefreshController() + { + if (m_avaloniaRefreshController != null) + return; + + // The controller owns the ONE coalesced, editing-aware refresh queue (PropChanged + // deliveries and host-requested re-shows alike); the host only supplies UI-thread + // deferral, so a late-queued refresh still re-checks "is the user typing now?" inside + // the controller's runner before recomposing. + m_avaloniaRefreshController = new AvaloniaRegionRefreshController( + Cache, + () => Clerk?.CurrentObject, + () => m_regionEditContext.Current?.IsOpen == true, + RefreshAvaloniaRegion, + new RefreshCoordinator(), + ScheduleOnUiThread); + // Global Undo/Redo while a fenced session is open would re-enter the UOW write lock + // (LockRecursionException); the guard settles the pending edit instead. + m_regionEditContext.AttachUndoGuard(Cache.ActionHandlerAccessor); + // The guard only hooks THIS window's undo stack — it cannot reach other windows' stacks, + // so Ctrl+Z in another window while this one holds an open session would still re-enter + // the write lock. Mitigate by settling whenever this view's top-level window deactivates + // (the user must focus another window before they can undo there). + var form = FindForm(); + if (form != null) + { + m_settleOnDeactivate = (s, e) => m_regionEditContext.Settle(); + form.Deactivate += m_settleOnDeactivate; + m_guardedForm = form; + } + } + + // UI-thread deferral for the controller's coalesced refresh queue: posting to the message + // queue lets the current call stack (commit/rollback PropChanged, the focus transition + // that triggered an auto-save) unwind before the view is rebuilt. + private void ScheduleOnUiThread(Action runner) + { + if (IsDisposed) + return; + if (IsHandleCreated) + BeginInvoke(runner); + else + runner(); + } + + /// + /// Shows the Avalonia surface for a record: the composed full-entry view when the record is a + /// lexical entry (first-slice fallback if composition fails), or the resource-backed + /// unsupported state otherwise. + /// + private void ShowAvaloniaEntry(ICmObject obj) + { + // Auto-save (14.4): a session still open from the previous record/edit settles before + // the region is replaced (commit when valid, roll back when not) — the same policy + // every host path shares; Replace's cancel-on-displace stays the safety net. + m_regionEditContext.Settle(); + + // 13.4 adapter hygiene: the hidden command-routing DataTree must never answer mediator + // commands for a PREVIOUS record — reset it whenever the shown record changes; the next + // right-click re-syncs it (EnsureMenuCommandAdapter). Without this, Insert Sense from + // the main menu could silently target the entry that was last right-clicked. + if (m_legacySurfaceInitialized && m_dataEntryForm?.Root != null && obj != null + && m_dataEntryForm.Root.Hvo != obj.Hvo) + { + m_dataEntryForm.Reset(); + } + + if (!(obj is ILexEntry lexEntry)) + { + m_regionEditContext.Clear(); + TearDownCompanionSlices(); + m_avaloniaEntryForm.ShowMessage(FwAvaloniaStrings.EntryTypeUnsupported); + return; + } + + // Viewing parity (11.x): honor the same View → Show Hidden Fields setting legacy DataTree + // reads (ShowHiddenFields-{tool}, local settings). + var toolName = m_propertyTable.GetStringProperty("currentContentControl", string.Empty); + var showHidden = m_propertyTable.GetBoolProperty("ShowHiddenFields-" + toolName, false, + PropertyTable.SettingsGroup.LocalSettings); + + LexicalEditRegionModel region = null; + IRegionEditContext editContext = null; + ComposedEntryRegion composed = null; + try + { + composed = FullEntryRegionComposer.Compose(lexEntry, Cache, showHidden); + if (composed != null) + { + region = composed.Model; + editContext = composed.EditContext; + } + } + catch (Exception e) + { + Debug.WriteLine("Full-entry composition failed; falling back to the first slice: " + e); + } + + if (region == null) + { + region = LexicalEditRegionBuilder.Build(lexEntry, Cache); + editContext = new LexicalEditRegionEditContext(lexEntry, Cache); + } + + // Hybrid companion lane: WinForms-only custom slices (the Chorus Messages notes bar) + // are realized for real in the host's companion strip and their placeholder rows are + // removed from the Avalonia model. Always runs (also clears the strip on fallback or + // when the layout no longer reaches a companion slice). + region = PromoteCompanionSlices(composed, region); + + // Re-showing mid-edit (record navigation, refresh delivery, Show Hidden Fields, window + // activation) must cancel the displaced context's open fenced session — orphaning the + // open undo task makes the shutdown Save throw "Commit at wrong place". + m_regionEditContext.Replace(editContext); + + EnsureAvaloniaRefreshController(); + m_avaloniaEntryForm.ShowRegion(region, editContext, + wsTag => LexicalEditRegionBuilder.ActivateKeyboardForWritingSystem(Cache, wsTag), + GetPersistedExpansionState, PersistExpansionState, + OnRegionMenuRequested); + } + + /// + /// Hybrid companion lane: tears down the previous companions, instantiates the real legacy + /// slice for each designated WinForms-only custom editor the composer found (today the + /// Chorus Messages notes bar — its NotesBarView cannot render inside Avalonia), hands the + /// slices' editor controls to the host's companion strip, and returns the region model with + /// the promoted placeholder rows removed. When the slice cannot be created (Chorus + /// unavailable) the row degrades to nothing — logged by AvaloniaCompanionSlices. + /// + private LexicalEditRegionModel PromoteCompanionSlices(ComposedEntryRegion composed, + LexicalEditRegionModel region) + { + TearDownCompanionSlices(); + + var promotions = AvaloniaCompanionSlices.SelectPromotions(composed?.CustomEditorFields); + if (promotions.Count == 0) + return region; + + var companionControls = new List(); + var promotedIds = new List(); + foreach (var binding in promotions) + { + // The unsupported row never renders for a designated companion slice, whether or + // not the real slice could be created. + promotedIds.Add(binding.FieldStableId); + + var slice = AvaloniaCompanionSlices.CreateCompanionSlice(binding, Cache); + if (slice == null) + continue; + var control = slice.Control; + if (control == null) + { + slice.Dispose(); + continue; + } + + // Track both: the strip reparents the control out of the slice, so the slice's + // Dispose no longer disposes it — TearDownCompanionSlices owns both lifetimes. + m_companionSlices.Add(slice); + m_companionControls.Add(control); + companionControls.Add(control); + } + + if (companionControls.Count > 0) + m_avaloniaEntryForm.SetCompanionControls(companionControls); + return AvaloniaCompanionSlices.RemovePromotedFields(region, promotedIds); + } + + /// + /// Disposes the companion slices created for the previously shown record and empties the + /// host's companion strip. The strip never disposes anything itself; this view created the + /// slices, so it disposes them (the editor control first — it was reparented into the strip + /// and is no longer reachable from the slice — then the slice, which releases its backing + /// services, e.g. MessageSlice's ChorusSystem). + /// + private void TearDownCompanionSlices() + { + if (m_companionSlices.Count == 0 && m_companionControls.Count == 0) + return; + + if (m_avaloniaEntryForm != null && !m_avaloniaEntryForm.IsDisposed) + m_avaloniaEntryForm.SetCompanionControls(null); + + foreach (var control in m_companionControls) + { + if (!control.IsDisposed) + control.Dispose(); + } + m_companionControls.Clear(); + + foreach (var slice in m_companionSlices) + { + if (!slice.IsDisposed) + slice.Dispose(); + } + m_companionSlices.Clear(); + } + + /// + /// Section 13: shows the SAME xCore-defined context menu the legacy slice shows, over the + /// Avalonia surface — the menu ids come from the layout (imported into the typed IR), the menu + /// is materialized from the window configuration and dispatched through the mediator, exactly + /// the legacy `DTMenuHandler.MakeSliceContextMenu` recipe (menu + mnuDataTree-Object; in-string + /// menus add mnuDataTree-MultiStringSlice). Command targeting (13.4) uses the approved baseline + /// adapter "command-menu-routing": the legacy DataTree + DTMenuHandler are initialized lazily and + /// kept HIDDEN purely as the command-target colleague chain, with CurrentSlice pointed at the + /// slice bound to the clicked row's object — never shown, never the active surface. + /// + private void OnRegionMenuRequested(RegionMenuRequest request) + { + try + { + // An adapter failure must not suppress the menu itself: items that need the hidden + // colleague chain disable, everything else still works (and the failure is logged). + try + { + EnsureMenuCommandAdapter(request.Field.ObjectHvo); + } + catch (Exception adapterError) + { + Debug.WriteLine("Region menu command adapter failed: " + adapterError); + } + + var ids = new List(); + switch (request.Kind) + { + case RegionMenuKind.ContextMenu: + ids.Add(request.Field.ContextMenuId); + ids.Add("mnuDataTree-MultiStringSlice"); + ids.Add("mnuDataTree-Object"); + break; + case RegionMenuKind.Hotlinks: + ids.Add(request.Field.HotlinksId); + break; + default: + ids.Add(request.Field.MenuId); + if (!string.IsNullOrEmpty(request.Field.HotlinksId)) + ids.Add(request.Field.HotlinksId); // section link commands stay reachable + ids.Add("mnuDataTree-Object"); + break; + } + + var idArray = ids.Where(id => !string.IsNullOrEmpty(id)).ToArray(); + var window = m_propertyTable.GetValue("window"); + + // 15.1: render the SAME xCore menu natively in Avalonia (identical items, enablement, + // and mediator dispatch — only the chrome changes). The WinForms adapter menu remains + // the fallback so a materialization failure never costs the user the menu. + try + { + var items = XCoreMenuBridge.BuildMenuItems(window, idArray); + if (items.Count > 0) + { + m_avaloniaEntryForm.ShowContextMenu(items); + return; + } + } + catch (Exception nativeMenuError) + { + Debug.WriteLine("Avalonia-native menu failed; falling back to the adapter menu: " + + nativeMenuError); + } + + window.ShowContextMenu(idArray, + new System.Drawing.Point(request.ScreenX, request.ScreenY), null, null); + } + catch (Exception e) + { + Debug.WriteLine("Region context menu failed: " + e); + } + } + + // Approved baseline adapter "command-menu-routing" (13.4): the hidden legacy DataTree + + // DTMenuHandler provide the colleague chain and CurrentSlice context the legacy command + // handlers require. Created lazily on first right-click; never attached/visible while the + // Avalonia surface is active. + private void EnsureMenuCommandAdapter(int targetHvo) + { + // The active-host contract (3.10) is enforced, not just documented: driving the hidden + // legacy DataTree is legal only through this approved baseline adapter. + ActiveHostContract.ForAvalonia("command-menu-routing") + .AssertLegacyDataTreeDriveAllowed("command-menu-routing"); + + if (!m_legacySurfaceInitialized) + { + EnsureLegacySurfaceInitialized(); + DetachLegacySurfaceFromPanel(); // adapter only: the Avalonia surface stays active + } + // 15.4: display logic gating on Visible (e.g. OnDisplayDataTreeInsert) treats the hidden + // adapter tree as active. + m_dataEntryForm.IsExternalCommandAdapter = true; + + var current = Clerk?.CurrentObject; + if (current == null) + return; + m_dataEntryForm.ShowObject(current, m_layoutName, m_layoutChoiceField, current, true); + + if (targetHvo == 0) + return; + foreach (var sliceObj in m_dataEntryForm.Slices) + { + if (sliceObj is Slice slice && slice.Object != null && slice.Object.Hvo == targetHvo) + { + m_dataEntryForm.CurrentSlice = slice; + break; + } + } + } + + // Viewing parity (11.8): expansion state persists per header stable id — in-session through the + // dictionary, across sessions through PropertyTable local settings, the legacy ExpansionStateKey + // behavior. Per-instance (review round 1): a process-wide static leaked state across + // projects/windows for the app lifetime. + private readonly Dictionary m_expansionStates = new Dictionary(); + + private bool? GetPersistedExpansionState(string stableId) + { + if (m_expansionStates.TryGetValue(stableId, out var expanded)) + return expanded; + var stored = m_propertyTable?.GetStringProperty("LexEditExpansion:" + stableId, null, + PropertyTable.SettingsGroup.LocalSettings); + return stored == null ? (bool?)null : stored == "1"; + } + + private void PersistExpansionState(string stableId, bool expanded) + { + m_expansionStates[stableId] = expanded; + if (m_propertyTable == null) + return; + var key = "LexEditExpansion:" + stableId; + m_propertyTable.SetProperty(key, expanded ? "1" : "0", PropertyTable.SettingsGroup.LocalSettings, false); + m_propertyTable.SetPropertyPersistence(key, true, PropertyTable.SettingsGroup.LocalSettings); + } + + // Re-resolves and re-shows the region for the current record from current domain state + // (after an external edit or this surface's commit/cancel). + private void RefreshAvaloniaRegion() + { + if (m_avaloniaEntryForm == null || !ShouldUseAvaloniaLexicalEdit) + return; + var current = Clerk?.CurrentObject; + if (current == null) + return; + + ShowAvaloniaEntry(current); + } + + private void OnAvaloniaRegionEditCompleted(object sender, EventArgs e) + { + // ONE re-show covers the completed edit AND any refresh held during it (the old + // NotifyEditCompleted + direct-refresh pair recomposed twice per commit): drop the held + // delivery and request a single coalesced refresh through the controller's queue. + if (m_avaloniaRefreshController != null) + { + m_avaloniaRefreshController.DiscardHeldRefresh(); + m_avaloniaRefreshController.RequestRefresh(); + } + else + { + RefreshAvaloniaRegion(); + } + } + + private void EnsureAvaloniaSurfaceActive() + { + if (m_avaloniaEntryForm == null) + EnsureAvaloniaSurfaceInitialized(); + + DetachLegacySurfaceFromPanel(); + m_avaloniaEntryForm.Show(); + m_avaloniaEntryForm.BringToFront(); + } + + private void EnsureLegacySurfaceVisible() + { + AttachLegacySurfaceToPanel(); + // The legacy DataTree builds its own MessageSlice/ChorusSystem; release the Avalonia + // lane's companions so two Chorus systems never sit on the project at once. + TearDownCompanionSlices(); + m_avaloniaEntryForm?.Hide(); + m_dataEntryForm.Show(); + m_dataEntryForm.BringToFront(); + } + + private void AttachLegacySurfaceToPanel() + { + if (m_dataEntryForm == null || m_panel == null) + return; + + if (!m_panel.Controls.Contains(m_dataEntryForm)) + m_panel.Controls.Add(m_dataEntryForm); + } + + private void DetachLegacySurfaceFromPanel() + { + if (m_dataEntryForm == null || m_panel == null) + return; + + m_dataEntryForm.Hide(); + if (m_panel.Controls.Contains(m_dataEntryForm)) + m_panel.Controls.Remove(m_dataEntryForm); + } + /// ------------------------------------------------------------------------------------ /// /// Base method saves any time you switch between records. @@ -521,16 +1024,6 @@ protected override void SetupDataContext() // (WinForms) and the active-host contract (task 3.10) would be violated for an Avalonia start. m_lexicalEditSurface = ResolveConfiguredLexicalEditSurface(); - //this will normally be the same name as the view, e.g. "basicEdit". This plus the name of the vector - //should give us a unique context for the dataTree control parameters. - - string persistContext = XmlUtils.GetOptionalAttributeValue(m_configurationParameters, "persistContext"); - - if (persistContext !="") - persistContext=m_vectorName+"."+persistContext+".DataTree"; - else - persistContext=m_vectorName+".DataTree"; - // Surface-agnostic: the record list bar must update regardless of which detail surface is active. Clerk.UpdateRecordTreeBarIfNeeded(); @@ -540,10 +1033,12 @@ protected override void SetupDataContext() // idle path (the inactive legacy DataTree is never built). if (!ShouldUseAvaloniaLexicalEdit) { - EnsureLegacySurfaceInitialized(persistContext); - m_avaloniaEntryForm?.Hide(); - m_dataEntryForm.Show(); - m_dataEntryForm.BringToFront(); + EnsureLegacySurfaceInitialized(); + EnsureLegacySurfaceVisible(); + } + else + { + DetachLegacySurfaceFromPanel(); } } @@ -609,10 +1104,13 @@ protected override void GetMessageAdditionalTargets(List collec if(!m_fullyInitialized) return; - if (!ShouldUseAvaloniaLexicalEdit && m_dataEntryForm != null) // Unlikely it is null, but I have observed it..JohnT. + // Legacy mode: the DataTree + menu handler are the normal targets. Avalonia mode: they + // participate ONLY once the lazy "command-menu-routing" baseline adapter exists (13.4), + // so the legacy command handlers can resolve and execute the context-menu commands. + if (m_legacySurfaceInitialized && m_dataEntryForm != null) collector.Add(m_dataEntryForm); - if (!ShouldUseAvaloniaLexicalEdit && m_menuHandler != null) + if (m_legacySurfaceInitialized && m_menuHandler != null) collector.Add(m_menuHandler); } diff --git a/Src/xWorks/RegionEditContextBase.cs b/Src/xWorks/RegionEditContextBase.cs new file mode 100644 index 0000000000..22db2a0dd1 --- /dev/null +++ b/Src/xWorks/RegionEditContextBase.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The ONE LCModel-backed session/validation implementation + /// (review finding C): owns the lazily opened fenced (opened + /// on the first staged edit, committed/cancelled as one global undo step) and the shared + /// required-lexeme validation rule, so the first-slice context and the full-entry composed + /// context cannot drift apart. Derived contexts supply only the field write routing. + /// + public abstract class RegionEditContextBase : IRegionEditContext + { + private LcmRegionEditSession _session; + + protected RegionEditContextBase(LcmCache cache, ILexEntry entry) + { + Cache = cache ?? throw new ArgumentNullException(nameof(cache)); + Entry = entry ?? throw new ArgumentNullException(nameof(entry)); + } + + protected LcmCache Cache { get; } + + protected ILexEntry Entry { get; } + + /// + public bool IsOpen => _session != null && _session.IsOpen; + + /// + public abstract bool TrySetText(LexicalEditRegionField regionField, string ws, string value); + + /// + public abstract bool TrySetOption(LexicalEditRegionField regionField, string optionKey); + + /// + public IReadOnlyList Validate() + { + // Validation seam (minimal rule set, deterministic order): an entry must keep some + // lexeme/citation form text. + var errors = new List(); + var lexeme = Entry.LexemeFormOA?.Form?.VernacularDefaultWritingSystem?.Text; + if (string.IsNullOrEmpty(lexeme)) + lexeme = Entry.CitationForm.VernacularDefaultWritingSystem?.Text; + if (string.IsNullOrWhiteSpace(lexeme)) + errors.Add(FwAvaloniaStrings.LexemeFormRequired); + return errors; + } + + /// + public void Commit() + { + _session?.Commit(); + } + + /// + public void Cancel() + { + _session?.Cancel(); + } + + /// Opens the fenced session on the first staged edit; later calls are no-ops. + protected void EnsureOpen() + { + if (IsOpen) + return; + _session = new LcmRegionEditSession(Cache, FwAvaloniaStrings.UndoEditEntry, FwAvaloniaStrings.RedoEditEntry); + } + } +} diff --git a/Src/xWorks/RegionEditContextHolder.cs b/Src/xWorks/RegionEditContextHolder.cs new file mode 100644 index 0000000000..6bff077d9c --- /dev/null +++ b/Src/xWorks/RegionEditContextHolder.cs @@ -0,0 +1,113 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.ComponentModel; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel.Application; +using SIL.LCModel.Core.KernelInterfaces; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Owns the host's current and enforces the lifecycle rules + /// that keep the fenced edit session (an open LCModel undo task) safe against the rest of the + /// app: + /// (1) a context with an open session is NEVER orphaned — re-showing the region swaps in a + /// fresh context and the displaced one is cancelled first (an orphaned open undo task makes + /// every later IUndoStackManager.Save() throw "Commit at wrong place.", which is fatal + /// at shutdown); + /// (2) is the single auto-save policy (14.4) every host path shares: + /// commit when validation is clean, roll back otherwise — navigation, go-away, undo and + /// dispose all settle the same way; + /// (3) the undo guard intercepts global Undo/Redo while a session is open: LCModel's + /// UndoStack.Undo() re-enters the non-recursive UOW write lock the open task's thread + /// already holds (LockRecursionException), so the guard settles the pending edit and cancels + /// that gesture — the next Ctrl+Z undoes the just-settled step normally. + /// + public sealed class RegionEditContextHolder + { + private IActionHandlerExtensions m_undoHook; + + /// The context currently bound to the shown region, or null. + public IRegionEditContext Current { get; private set; } + + /// + /// Makes the current context, cancelling the previous context's + /// open session (if any). Re-assigning the same instance is a no-op so a live edit is + /// never killed by redundant wiring. Hosts normally first; this + /// cancel is the safety net, not the auto-save path. + /// + public void Replace(IRegionEditContext next) + { + var previous = Current; + if (!ReferenceEquals(previous, next) && previous != null && previous.IsOpen) + previous.Cancel(); + Current = next; + } + + /// Drops the current context, cancelling its open session (if any). + public void Clear() + { + Replace(null); + } + + /// + /// Auto-save (14.4): closes any open session — committing when validation is clean, + /// rolling back otherwise (an invalid state is never silently persisted). No-op when + /// nothing is open. + /// + public void Settle() + { + var current = Current; + if (current == null || !current.IsOpen) + return; + try + { + if (current.Validate().Count == 0) + current.Commit(); + else + current.Cancel(); + } + catch (System.Exception e) + { + // Settling runs on navigation and teardown paths that must not die (e.g. the + // entry was deleted under the open session); rolling back is always a safe close. + SIL.Reporting.Logger.WriteError(e); + if (current.IsOpen) + current.Cancel(); + } + } + + /// + /// Intercepts global Undo/Redo for the given action handler while a session is open (see + /// class remarks). Detaches any previously attached handler first. + /// + public void AttachUndoGuard(IActionHandler actionHandler) + { + DetachUndoGuard(); + m_undoHook = actionHandler as IActionHandlerExtensions; + if (m_undoHook != null) + m_undoHook.DoingUndoOrRedo += OnDoingUndoOrRedo; + } + + /// Stops intercepting Undo/Redo. Safe to call when not attached. + public void DetachUndoGuard() + { + if (m_undoHook == null) + return; + m_undoHook.DoingUndoOrRedo -= OnDoingUndoOrRedo; + m_undoHook = null; + } + + private void OnDoingUndoOrRedo(CancelEventArgs e) + { + if (Current?.IsOpen != true) + return; + // Settling closes the task and releases the write lock; cancelling the gesture keeps + // its meaning predictable — this press closed the pending edit, the next one undoes it. + Settle(); + e.Cancel = true; + } + } +} diff --git a/Src/xWorks/XCoreMenuBridge.cs b/Src/xWorks/XCoreMenuBridge.cs new file mode 100644 index 0000000000..252ac5afa9 --- /dev/null +++ b/Src/xWorks/XCoreMenuBridge.cs @@ -0,0 +1,92 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Section 15.1: converts an xCore context-menu into the neutral + /// model the Avalonia surface renders as a native MenuFlyout. + /// Labels, enablement, checkmarks, submenus, and execution all run through the SAME xCore + /// machinery the WinForms adapter uses (GetDisplayProperties → mediator Display* round-trip; + /// OnClick → mediator command dispatch) — only the chrome changes. Because this consumes the + /// shared engine, it serves every DTMenuHandler-hosting tool (Grammar, Notebook, Lists, + /// Words), not just the Lexicon. + /// + public static class XCoreMenuBridge + { + /// + /// Materializes the merged context menu for the given menu ids (the same merge + /// XWindow.ShowContextMenu performs) as a renderable item tree. Empty when nothing + /// resolves — callers fall back to the legacy adapter menu. + /// + public static IReadOnlyList BuildMenuItems(XWindow window, string[] menuIds) + { + var group = window?.GetContextMenuChoiceGroup(menuIds); + if (group == null) + return new List(); + group.PopulateNow(); + return Convert(group); + } + + private static List Convert(ChoiceGroup group) + { + var items = new List(); + foreach (var member in group) + { + // SeparatorChoice subclasses ChoiceBase: test it first. + if (member is SeparatorChoice) + { + items.Add(RegionMenuItem.Separator()); + } + else if (member is ChoiceGroup submenu) + { + submenu.PopulateNow(); + var children = Convert(submenu); + if (children.Count == 0) + continue; + var display = submenu.GetDisplayProperties(); + if (!display.Visible) + continue; + items.Add(new RegionMenuItem(StripAccelerator(display.Text), display.Enabled, + display.Checked, children)); + } + else if (member is ChoiceBase choice) + { + var display = choice.GetDisplayProperties(); + if (!display.Visible) + continue; + var captured = choice; + items.Add(new RegionMenuItem(StripAccelerator(display.Text), display.Enabled, + display.Checked, null, () => captured.OnClick(null, EventArgs.Empty))); + } + } + + TrimSeparators(items); + return items; + } + + // xCore labels mark the accelerator with '_' (WinForms '&'); Avalonia headers show it raw. + private static string StripAccelerator(string text) + => (text ?? string.Empty).Replace("_", string.Empty); + + // Hidden items can strand separators at the edges or double them up. + private static void TrimSeparators(List items) + { + for (var i = items.Count - 1; i > 0; i--) + { + if (items[i].IsSeparator && items[i - 1].IsSeparator) + items.RemoveAt(i); + } + while (items.Count > 0 && items[items.Count - 1].IsSeparator) + items.RemoveAt(items.Count - 1); + while (items.Count > 0 && items[0].IsSeparator) + items.RemoveAt(0); + } + } +} diff --git a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs new file mode 100644 index 0000000000..2f2536c755 --- /dev/null +++ b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs @@ -0,0 +1,306 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// 6.3 / B7 / B8 (xml-retirement-blockers) — reference chooser write-back: possibility-list + /// reference fields compose as EDITABLE rows instead of read-only joined text. Atomic refs + /// (possAtomicReference, e.g. Status) become Chooser rows whose options come from the field's + /// possibility list (legacy obj.ReferenceTargetOwner(flid), the same lane + /// PossibilityAtomicReferenceSlice uses); vector refs (possVectorReference / + /// SemDomVectorReference, e.g. Semantic Domains, Usages, Anthropology Categories) become + /// ReferenceVector rows carrying the current items plus the list's options, edited through + /// Add/Remove on the fenced session (sda Replace on the vector flid, the legacy + /// VectorReferenceView update). Deep lists (semantic domains) carry hierarchy on the options + /// (B8: RegionChoiceOption.Depth) so the chooser can render the legacy indented tree. + /// + [TestFixture] + public class FullEntryRegionReferenceChooserTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + private ILexSense m_sense; + private ICmSemanticDomain m_domainUniverse; + private ICmSemanticDomain m_domainSky; + private ICmSemanticDomain m_domainWeather; + private ICmPossibility m_statusConfirmed; + private ICmPossibility m_statusPending; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + EnsureLists(); + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(m_sense); + m_sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + m_sense.SemanticDomainsRC.Add(m_domainUniverse); + }); + } + + // The memory-only fixture ships no list content; build the minimal real lists the senses + // reference — a HIERARCHICAL semantic domain list (B8) and a flat status list. + private void EnsureLists() + { + var listFactory = Cache.ServiceLocator.GetInstance(); + if (Cache.LangProject.SemanticDomainListOA == null) + Cache.LangProject.SemanticDomainListOA = listFactory.Create(); + var semDomList = Cache.LangProject.SemanticDomainListOA; + if (semDomList.PossibilitiesOS.Count == 0) + { + var semDomFactory = Cache.ServiceLocator.GetInstance(); + m_domainUniverse = semDomFactory.Create(); + semDomList.PossibilitiesOS.Add(m_domainUniverse); + m_domainUniverse.Name.SetAnalysisDefaultWritingSystem("Universe"); + m_domainSky = semDomFactory.Create(); + m_domainUniverse.SubPossibilitiesOS.Add(m_domainSky); + m_domainSky.Name.SetAnalysisDefaultWritingSystem("Sky"); + m_domainWeather = semDomFactory.Create(); + m_domainUniverse.SubPossibilitiesOS.Add(m_domainWeather); + m_domainWeather.Name.SetAnalysisDefaultWritingSystem("Weather"); + } + else + { + m_domainUniverse = (ICmSemanticDomain)semDomList.PossibilitiesOS[0]; + m_domainSky = (ICmSemanticDomain)m_domainUniverse.SubPossibilitiesOS[0]; + m_domainWeather = (ICmSemanticDomain)m_domainUniverse.SubPossibilitiesOS[1]; + } + + if (Cache.LangProject.StatusOA == null) + Cache.LangProject.StatusOA = listFactory.Create(); + var statusList = Cache.LangProject.StatusOA; + if (statusList.PossibilitiesOS.Count == 0) + { + var possibilityFactory = Cache.ServiceLocator.GetInstance(); + m_statusConfirmed = possibilityFactory.Create(); + statusList.PossibilitiesOS.Add(m_statusConfirmed); + m_statusConfirmed.Name.SetAnalysisDefaultWritingSystem("Confirmed"); + m_statusPending = possibilityFactory.Create(); + statusList.PossibilitiesOS.Add(m_statusPending); + m_statusPending.Name.SetAnalysisDefaultWritingSystem("Pending"); + } + else + { + m_statusConfirmed = statusList.PossibilitiesOS[0]; + m_statusPending = statusList.PossibilitiesOS[1]; + } + + if (Cache.LangProject.LexDbOA.UsageTypesOA == null) + Cache.LangProject.LexDbOA.UsageTypesOA = listFactory.Create(); + if (Cache.LangProject.LexDbOA.UsageTypesOA.PossibilitiesOS.Count == 0) + { + var usage = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.UsageTypesOA.PossibilitiesOS.Add(usage); + usage.Name.SetAnalysisDefaultWritingSystem("archaic"); + } + + if (Cache.LangProject.AnthroListOA == null) + Cache.LangProject.AnthroListOA = listFactory.Create(); + if (Cache.LangProject.AnthroListOA.PossibilitiesOS.Count == 0) + { + var anthro = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.AnthroListOA.PossibilitiesOS.Add(anthro); + anthro.Name.SetAnalysisDefaultWritingSystem("Kinship"); + } + } + + private ComposedEntryRegion Compose(bool showHidden = false) + => FullEntryRegionComposer.Compose(m_entry, Cache, showHidden); + + private static LexicalEditRegionField VectorField(ComposedEntryRegion composed, string field) + => composed.Model.Fields.Single(f => f.Field == field && f.Kind == RegionFieldKind.ReferenceVector); + + [Test] + public void Compose_SemanticDomains_IsEditableReferenceVector_WithHierarchicalOptions() + { + var composed = Compose(); + var field = VectorField(composed, "SemanticDomains"); + + Assert.That(field.IsEditable, Is.True, "6.3: the vector reference is no longer read-only text"); + Assert.That(field.Items.Select(i => i.Key), Is.EqualTo(new[] { m_domainUniverse.Guid.ToString() }), + "the current items ride the row in vector order"); + Assert.That(field.Items.Single().Name, Does.Contain("Universe")); + + // B8: the option list is the WHOLE possibility tree, hierarchy carried as Depth, in the + // list's own (tree) order — exactly what the legacy chooser tree shows. + Assert.That(field.Options.Select(o => o.Key), Is.EqualTo(new[] + { + m_domainUniverse.Guid.ToString(), m_domainSky.Guid.ToString(), m_domainWeather.Guid.ToString() + }), + "options walk the list tree in document order (parent before children)"); + Assert.That(field.Options.Select(o => o.Depth), Is.EqualTo(new[] { 0, 1, 1 }), + "sub-domains carry their hierarchy level for the indented chooser"); + } + + [Test] + public void Edit_SemanticDomains_AddItem_CommitsAsOneUndoStep() + { + var composed = Compose(); + var field = VectorField(composed, "SemanticDomains"); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_domainSky.Guid.ToString()), Is.True, + "adding stages through the fenced session (sda Replace on the vector flid)"); + composed.EditContext.Commit(); + + Assert.That(m_sense.SemanticDomainsRC.Select(d => d.Guid), + Is.EquivalentTo(new[] { m_domainUniverse.Guid, m_domainSky.Guid })); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.SemanticDomainsRC.Select(d => d.Guid), + Is.EquivalentTo(new[] { m_domainUniverse.Guid }), + "the add is one step on the global undo stack"); + } + + [Test] + public void Edit_SemanticDomains_RemoveItem_Commits() + { + var composed = Compose(); + var field = VectorField(composed, "SemanticDomains"); + + Assert.That(composed.EditContext.TryRemoveReferenceItem(field, m_domainUniverse.Guid.ToString()), + Is.True); + composed.EditContext.Commit(); + + Assert.That(m_sense.SemanticDomainsRC, Is.Empty); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.SemanticDomainsRC.Select(d => d.Guid), + Is.EquivalentTo(new[] { m_domainUniverse.Guid })); + } + + [Test] + public void Edit_ReferenceVector_RejectsGarbageUnknownAndDuplicates_WithoutOpeningASession() + { + var composed = Compose(); + var field = VectorField(composed, "SemanticDomains"); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, "not-a-guid"), Is.False); + Assert.That(composed.EditContext.TryAddReferenceItem(field, System.Guid.NewGuid().ToString()), + Is.False, "a guid outside the field's possibility list must not stage"); + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_domainUniverse.Guid.ToString()), + Is.False, "duplicates are rejected, like the legacy chooser"); + Assert.That(composed.EditContext.TryRemoveReferenceItem(field, m_domainSky.Guid.ToString()), + Is.False, "removing an item that is not in the vector must not stage"); + Assert.That(composed.EditContext.IsOpen, Is.False, "rejected edits must not open the fence"); + Assert.That(m_sense.SemanticDomainsRC.Count, Is.EqualTo(1), "nothing was written"); + } + + [Test] + public void Compose_EmptyAlwaysVisibleVector_StillOffersTheAddSlotOptions() + { + // SemanticDomains is visibility="always" in LexSense/Normal: with no items the row still + // composes, editable, with the full option list — the legacy empty slice with the + // type-ahead add slot. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => m_sense.SemanticDomainsRC.Clear()); + + var composed = Compose(); + var field = VectorField(composed, "SemanticDomains"); + + Assert.That(field.Items, Is.Empty); + Assert.That(field.IsEditable, Is.True); + Assert.That(field.Options.Count, Is.EqualTo(3), "the add slot offers the whole list"); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_domainWeather.Guid.ToString()), Is.True); + composed.EditContext.Commit(); + Assert.That(m_sense.SemanticDomainsRC.Single().Guid, Is.EqualTo(m_domainWeather.Guid)); + } + + [Test] + public void Compose_UsagesAndAnthroCategories_AreEditableReferenceVectors() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_sense.UsageTypesRC.Add(Cache.LangProject.LexDbOA.UsageTypesOA.PossibilitiesOS[0]); + m_sense.AnthroCodesRC.Add((ICmAnthroItem)Cache.LangProject.AnthroListOA.PossibilitiesOS[0]); + }); + + var composed = Compose(); + var usages = VectorField(composed, "UsageTypes"); + Assert.That(usages.IsEditable, Is.True); + Assert.That(usages.Options.Select(o => o.Key), + Does.Contain(Cache.LangProject.LexDbOA.UsageTypesOA.PossibilitiesOS[0].Guid.ToString()), + "Usages options come from the usage-types possibility list"); + + var anthro = VectorField(composed, "AnthroCodes"); + Assert.That(anthro.IsEditable, Is.True); + Assert.That(anthro.Options.Select(o => o.Key), + Does.Contain(Cache.LangProject.AnthroListOA.PossibilitiesOS[0].Guid.ToString()), + "Anthropology Categories options come from the anthro list"); + } + + [Test] + public void Compose_SenseStatus_AtomicPossibilityReference_IsChooser_AndCommits() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => m_sense.StatusRA = m_statusConfirmed); + + var composed = Compose(); + var status = composed.Model.Fields.Single(f => f.Field == "Status" && f.ObjectHvo == m_sense.Hvo); + + Assert.That(status.Kind, Is.EqualTo(RegionFieldKind.Chooser), + "possAtomicReference takes the chooser lane, like the morph type"); + Assert.That(status.IsEditable, Is.True); + Assert.That(status.SelectedOptionKey, Is.EqualTo(m_statusConfirmed.Guid.ToString())); + Assert.That(status.Options.Select(o => o.Key), Is.EqualTo(new[] + { + m_statusConfirmed.Guid.ToString(), m_statusPending.Guid.ToString() + }), + "options come from the field's possibility list (ReferenceTargetOwner), in list order"); + + Assert.That(composed.EditContext.TrySetOption(status, m_statusPending.Guid.ToString()), Is.True); + composed.EditContext.Commit(); + Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusPending)); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusConfirmed)); + } + + [Test] + public void Edit_AtomicChooser_RejectsKeysOutsideTheList_WithoutOpeningASession() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => m_sense.StatusRA = m_statusConfirmed); + + var composed = Compose(); + var status = composed.Model.Fields.Single(f => f.Field == "Status" && f.ObjectHvo == m_sense.Hvo); + + Assert.That(composed.EditContext.TrySetOption(status, "garbage"), Is.False); + Assert.That(composed.EditContext.TrySetOption(status, m_domainSky.Guid.ToString()), Is.False, + "a possibility from ANOTHER list must not be assignable"); + Assert.That(composed.EditContext.IsOpen, Is.False); + Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusConfirmed)); + } + + // B7: a chooserInfo guicontrol "...FlatList" spec means the legacy chooser presents the list + // FLAT (e.g. PeopleFlatList, EnvironmentFlatList); the option builder honors it by emitting + // depth-0 options while keeping document order. + [Test] + public void BuildPossibilityOptions_FlatSpec_FlattensTheHierarchy() + { + var hierarchical = FullEntryRegionComposer.BuildPossibilityOptions( + Cache.LangProject.SemanticDomainListOA, flat: false); + Assert.That(hierarchical.Select(o => o.Depth), Is.EqualTo(new[] { 0, 1, 1 })); + + var flat = FullEntryRegionComposer.BuildPossibilityOptions( + Cache.LangProject.SemanticDomainListOA, flat: true); + Assert.That(flat.Select(o => o.Key), Is.EqualTo(hierarchical.Select(o => o.Key)), + "flattening keeps the document order"); + Assert.That(flat.Select(o => o.Depth), Is.All.EqualTo(0), + "a FlatList guicontrol spec suppresses the hierarchy"); + } + } +} diff --git a/Src/xWorks/xWorksTests/FwTsStringClipboardTests.cs b/Src/xWorks/xWorksTests/FwTsStringClipboardTests.cs new file mode 100644 index 0000000000..969493548c --- /dev/null +++ b/Src/xWorks/xWorksTests/FwTsStringClipboardTests.cs @@ -0,0 +1,108 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Windows.Forms; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwUtils; +using SIL.FieldWorks.Common.RootSites; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Task 3.13 — the shared cross-framework clipboard seam. Proves the bridge speaks the legacy + /// "TsString" + UnicodeText OS clipboard contract in both directions: what the bridge + /// writes, legacy code reads (same format), and what legacy + /// EditingHelper writes, the bridge reads — with multi-writing-system runs preserved. + /// + [TestFixture] + public class FwTsStringClipboardTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase + { + public override void TestSetup() + { + base.TestSetup(); + ClipboardUtils.Manager.SetClipboardAdapter(new ClipboardStub()); + } + + private FwTsStringClipboard CreateClipboard() + => new FwTsStringClipboard(Cache.WritingSystemFactory); + + private ITsString MakeMultiWsString() + { + var bldr = TsStringUtils.MakeString("casa", Cache.DefaultVernWs).GetBldr(); + bldr.ReplaceTsString(bldr.Length, bldr.Length, + TsStringUtils.MakeString(" house", Cache.DefaultAnalWs)); + return bldr.GetString(); + } + + [Test] + public void RoundTrip_MultiWsString_PreservesWritingSystemRuns() + { + var clipboard = CreateClipboard(); + var original = MakeMultiWsString(); + + clipboard.SetText(clipboard.FromTsString(original)); + var payload = clipboard.GetText(); + + Assert.That(payload, Is.Not.Null); + Assert.That(payload.PlainText, Is.EqualTo("casa house")); + Assert.That(payload.RichXml, Is.Not.Null.And.Not.Empty, "the rich lane must survive the clipboard"); + + var roundTripped = clipboard.ToTsString(payload); + Assert.That(roundTripped.Text, Is.EqualTo(original.Text)); + Assert.That(roundTripped.RunCount, Is.EqualTo(2), "both writing-system runs survive"); + Assert.That(roundTripped.get_WritingSystem(0), Is.EqualTo(Cache.DefaultVernWs)); + Assert.That(roundTripped.get_WritingSystem(1), Is.EqualTo(Cache.DefaultAnalWs)); + } + + [Test] + public void SetText_WritesTheLegacyTsStringFormat_LegacyReaderSeesIt() + { + var clipboard = CreateClipboard(); + clipboard.SetText(clipboard.FromTsString(MakeMultiWsString())); + + // Read exactly the way legacy EditingHelper.GetTsStringFromClipboard does. + var dataObject = ClipboardUtils.GetDataObject(); + var wrapper = dataObject.GetData(TsStringWrapper.TsStringFormat) as TsStringWrapper; + Assert.That(wrapper, Is.Not.Null, "legacy surfaces must find the TsString format the bridge wrote"); + Assert.That(wrapper.GetTsString(Cache.WritingSystemFactory).Text, Is.EqualTo("casa house")); + Assert.That(dataObject.GetData(DataFormats.UnicodeText), Is.EqualTo("casa house"), + "the plain-text lane is present for external consumers"); + } + + [Test] + public void GetText_ReadsWhatLegacyEditingHelperWrote() + { + // Write through the real legacy code path. + SIL.FieldWorks.Common.RootSites.EditingHelper.SetTsStringOnClipboard( + MakeMultiWsString(), false, Cache.WritingSystemFactory); + + var payload = CreateClipboard().GetText(); + Assert.That(payload, Is.Not.Null); + Assert.That(payload.RichXml, Is.Not.Null, "a legacy Views copy must surface the rich lane"); + Assert.That(CreateClipboard().ToTsString(payload).Text, Is.EqualTo("casa house")); + } + + [Test] + public void GetText_PlainTextOnlyClipboard_FallsBackToPlainLane() + { + ClipboardUtils.SetDataObject(new DataObject(DataFormats.UnicodeText, "plain only")); + + var payload = CreateClipboard().GetText(); + Assert.That(payload, Is.Not.Null); + Assert.That(payload.PlainText, Is.EqualTo("plain only")); + Assert.That(payload.RichXml, Is.Null, "no rich lane is invented for external text"); + } + + [Test] + public void GetText_EmptyClipboard_ReturnsNull() + { + var clipboard = CreateClipboard(); + Assert.That(clipboard.GetText(), Is.Null); + Assert.That(clipboard.ContainsText(), Is.False); + } + } +} diff --git a/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs b/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs deleted file mode 100644 index 1152736bca..0000000000 --- a/Src/xWorks/xWorksTests/LexicalEditPocMapperTests.cs +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2026 SIL International -// This software is licensed under the LGPL, version 2.1 or later -// (http://www.gnu.org/licenses/lgpl-2.1.html) - -using NUnit.Framework; -using SIL.FieldWorks.Common.FwUtils; -using SIL.LCModel; -using SIL.LCModel.Core.Text; - -namespace SIL.FieldWorks.XWorks.xWorksTests -{ - [TestFixture] - public class LexicalEditPocMapperTests : MemoryOnlyBackendProviderRestoredForEachTestTestBase - { - [Test] - public void CreateDto_LexEntry_MapsLexemeFormMorphTypeAndFirstSenseGloss() - { - var entry = Cache.ServiceLocator.GetInstance().Create(); - var lexemeForm = Cache.ServiceLocator.GetInstance().Create(); - entry.LexemeFormOA = lexemeForm; - lexemeForm.Form.set_String( - Cache.DefaultVernWs, - TsStringUtils.MakeString("kazi", Cache.DefaultVernWs)); - entry.CitationForm.VernacularDefaultWritingSystem = - TsStringUtils.MakeString("citation", Cache.DefaultVernWs); - lexemeForm.MorphTypeRA = - Cache.ServiceLocator.GetInstance().GetObject(MoMorphTypeTags.kguidMorphPrefix); - - var sense = Cache.ServiceLocator.GetInstance().Create(); - entry.SensesOS.Add(sense); - sense.Gloss.set_String(Cache.DefaultAnalWs, "to work"); - - var dto = LexicalEditPocMapper.CreateDto(entry, Cache); - - Assert.That(dto, Is.Not.Null); - Assert.That(dto.LexemeForm.Count, Is.EqualTo(1)); - Assert.That(dto.LexemeForm[0].Value, Is.EqualTo("kazi")); - Assert.That(dto.MorphTypeKey, Is.EqualTo("prefix")); - Assert.That(dto.SenseGloss.Count, Is.EqualTo(1)); - Assert.That(dto.SenseGloss[0].Value, Is.EqualTo("to work")); - } - - [Test] - public void CreateDto_NonLexEntry_ReturnsNull() - { - var sense = Cache.ServiceLocator.GetInstance().Create(); - Assert.That(LexicalEditPocMapper.CreateDto(sense, Cache), Is.Null); - } - } -} diff --git a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs new file mode 100644 index 0000000000..0137384908 --- /dev/null +++ b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs @@ -0,0 +1,1234 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Globalization; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; +using SIL.LCModel.Application; +using SIL.LCModel.Core.Cellar; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Tasks 6.8/6.10 — the first editable slice runs through real LCModel seams: a fenced edit + /// session whose commit is ONE step on the single global undo stack legacy surfaces share + /// (cross-framework Ctrl+Z by construction), cancel rolls everything back, and the validation + /// seam gates empty lexeme forms. Task 3.15 — the refresh controller follows the real + /// PropChanged bus, holding refreshes while this surface's own session is open. + /// + [TestFixture] + public class LexicalEditRegionEditingTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + }); + } + + private string LexemeText => m_entry.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text; + + private string GlossText => m_entry.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text; + + // The edit-context seam keys fixed slices by Field name; tests address fields the same way + // the view does — through a region field object. + internal static SIL.FieldWorks.Common.FwAvalonia.Region.LexicalEditRegionField F(string field) + => new SIL.FieldWorks.Common.FwAvalonia.Region.LexicalEditRegionField( + "test/" + field, field, field, null, + SIL.FieldWorks.Common.FwAvalonia.Region.RegionFieldKind.Text, + SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.EditorClassification.Known, + null, null, SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.SurfaceRouting.Product, + null, null, null); + + [Test] + public void Commit_MultiFieldEdit_IsOneStepOnTheGlobalUndoStack() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + + Assert.That(context.TrySetText(F("Form"), "vern", "perro"), Is.True); + Assert.That(context.TrySetText(F("Gloss"), "anal", "dog"), Is.True); + Assert.That(context.IsOpen, Is.True, "the fenced session opens on the first staged edit"); + context.Commit(); + Assert.That(context.IsOpen, Is.False); + + Assert.That(LexemeText, Is.EqualTo("perro")); + Assert.That(GlossText, Is.EqualTo("dog")); + + // One global undo step covers both fields, on the same action handler legacy surfaces use. + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True); + Cache.ActionHandlerAccessor.Undo(); // one undo = the whole region edit + Assert.That(LexemeText, Is.EqualTo("casa"), "one undo reverts every field of the session"); + Assert.That(GlossText, Is.EqualTo("house")); + + Cache.ActionHandlerAccessor.Redo(); + Assert.That(LexemeText, Is.EqualTo("perro"), "redo replays the whole session step"); + Assert.That(GlossText, Is.EqualTo("dog")); + } + + [Test] + public void Cancel_RollsBackEveryStagedEdit_AndLeavesNoUndoStep() + { + var canUndoBefore = Cache.ActionHandlerAccessor.CanUndo(); + var context = new LexicalEditRegionEditContext(m_entry, Cache); + + context.TrySetText(F("Form"), "vern", "perro"); + context.TrySetText(F("Gloss"), "anal", "dog"); + context.Cancel(); + + Assert.That(LexemeText, Is.EqualTo("casa"), "cancel rolls back all staged edits"); + Assert.That(GlossText, Is.EqualTo("house")); + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.EqualTo(canUndoBefore), + "a cancelled session leaves nothing on the undo stack"); + + // Idempotence: a second cancel/commit is a safe no-op. + context.Cancel(); + context.Commit(); + } + + [Test] + public void TrySetOption_MorphType_ResolvesByGuid_AndCommits() + { + var morphTypes = Cache.LangProject.LexDbOA.MorphTypesOA.ReallyReallyAllPossibilities + .OfType() + .ToList(); + Assert.That(morphTypes.Count, Is.GreaterThanOrEqualTo(2), "fixture project ships morph types"); + var target = morphTypes.First(mt => mt != m_entry.LexemeFormOA.MorphTypeRA); + + var context = new LexicalEditRegionEditContext(m_entry, Cache); + Assert.That(context.TrySetOption(F("MorphType"), target.Guid.ToString()), Is.True); + context.Commit(); + + Assert.That(m_entry.LexemeFormOA.MorphTypeRA, Is.EqualTo(target)); + } + + [Test] + public void TrySetOption_RejectsUnknownKeysAndFields_WithoutOpeningASession() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + Assert.That(context.TrySetOption(F("MorphType"), "not-a-guid"), Is.False); + Assert.That(context.TrySetOption(F("MorphType"), System.Guid.NewGuid().ToString()), Is.False, + "a guid that is not a morph type must not stage"); + Assert.That(context.TrySetOption(F("Gloss"), "x"), Is.False); + Assert.That(context.IsOpen, Is.False, "rejected edits must not open the fence"); + } + + [Test] + public void Validate_RequiresLexemeOrCitationForm() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + Assert.That(context.Validate(), Is.Empty); + + context.TrySetText(F("Form"), "vern", ""); + Assert.That(context.Validate(), Is.EqualTo(new[] { FwAvaloniaStrings.LexemeFormRequired }), + "emptying the lexeme form trips the required-field rule"); + + context.TrySetText(F("Form"), "vern", "gato"); + Assert.That(context.Validate(), Is.Empty); + context.Cancel(); + } + + // Finding-3: writing-system identity is the unique IETF tag (ws.Id), never only the + // user-editable Abbreviation — an unmatched abbreviation must not silently write to the + // default writing system. + [Test] + public void TrySetText_AddressesTheWritingSystemByIetfTag() + { + var container = Cache.ServiceLocator.WritingSystems; + CoreWritingSystemDefinition second = null; + string originalAbbrev = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + container.AddToCurrentVernacularWritingSystems(second); + originalAbbrev = second.Abbreviation; + second.Abbreviation = "Spa"; // the row gutter label, not the identity + }); + try + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + Assert.That(context.TrySetText(F("Form"), second.Id, "kasa"), Is.True); + context.Commit(); + + Assert.That(m_entry.LexemeFormOA.Form.get_String(second.Handle).Text, Is.EqualTo("kasa"), + "the IETF tag addresses its own alternative"); + Assert.That(LexemeText, Is.EqualTo("casa"), + "the default alternative must not absorb an edit addressed to another writing system"); + Cache.ActionHandlerAccessor.Undo(); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + second.Abbreviation = originalAbbrev; + container.VernacularWritingSystems.Remove(second); + container.CurrentVernacularWritingSystems.Remove(second); + }); + } + } + + // Review round 2: an entirely unknown ws key must be rejected (no session, no write) instead + // of silently landing on the DEFAULT alternative — matching ComposedRegionEditContext. Only + // real ids/abbreviations and the legacy "vern"/"anal" first-slice aliases resolve. + [Test] + public void TrySetText_UnknownWsKey_IsRejectedWithoutOpeningASession() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + + Assert.That(context.TrySetText(F("Form"), "no-such-ws", "perro"), Is.False, + "an unknown ws key must not write to the default alternative"); + Assert.That(context.TrySetText(F("Gloss"), "xkcd", "dog"), Is.False); + Assert.That(context.IsOpen, Is.False, "a rejected key must not open the fenced session"); + Assert.That(LexemeText, Is.EqualTo("casa"), "nothing was written"); + + // The legacy aliases keep resolving to the defaults (the fixed first-slice definition). + Assert.That(context.TrySetText(F("Form"), "vern", "perro"), Is.True); + Assert.That(context.TrySetText(F("Gloss"), "anal", "dog"), Is.True); + context.Cancel(); + } + + [Test] + public void RefreshController_ExternalEditToDisplayedEntry_TriggersRefresh() + { + var refreshes = 0; + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator())) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("edited elsewhere", Cache.DefaultVernWs))); + + Assert.That(refreshes, Is.GreaterThanOrEqualTo(1), + "a legacy-side edit to the displayed entry must reach the Avalonia surface through the real PropChanged bus"); + } + } + + [Test] + public void RefreshController_EditToAnotherEntry_DoesNotRefresh() + { + ILexEntry other = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + other = Cache.ServiceLocator.GetInstance().Create()); + + var refreshes = 0; + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator())) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + other.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("unrelated", Cache.DefaultVernWs))); + + Assert.That(refreshes, Is.EqualTo(0), "changes outside the displayed entry are not relevant"); + } + } + + [Test] + public void RefreshController_HoldsRefreshWhileEditing_DeliversOnCompletion() + { + var editing = true; + var refreshes = 0; + using (var controller = new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => editing, () => refreshes++, new RefreshCoordinator())) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("raced", Cache.DefaultVernWs))); + Assert.That(refreshes, Is.EqualTo(0), "refreshes are held while the surface's own session is open"); + + editing = false; + controller.NotifyEditCompleted(); + Assert.That(refreshes, Is.EqualTo(1), "the held refresh is delivered once on edit completion"); + } + } + + [Test] + public void RefreshController_Dispose_StopsListening() + { + var refreshes = 0; + var controller = new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator()); + controller.Dispose(); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("after dispose", Cache.DefaultVernWs))); + Assert.That(refreshes, Is.EqualTo(0)); + } + } + + /// + /// Sections 6/7 — the COMPLETE lexical edit view: `FullEntryRegionComposer` walks the live + /// compiled `LexEntry/Normal` layout across objects (entry → lexeme form → senses), emits + /// headers/indentation, hides empty ifdata fields, binds every editable field to LCModel by + /// metadata, and edits commit through the fenced session as one global undo step. + /// + [TestFixture] + public class FullEntryRegionComposerTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_entry.CitationForm.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa-cit", Cache.DefaultVernWs)); + var senseFactory = Cache.ServiceLocator.GetInstance(); + for (var i = 0; i < 2; i++) + { + var sense = senseFactory.Create(); + m_entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("gloss " + (i + 1), Cache.DefaultAnalWs)); + } + }); + } + + [Test] + public void Compose_WalksTheFullCompiledLayout_AcrossObjects() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + Assert.That(composed, Is.Not.Null, "the shipped layouts must compose"); + var fields = composed.Model.Fields; + + Assert.That(fields.Count, Is.GreaterThan(10), + $"the complete view is far richer than the 3-field first slice (got {fields.Count})"); + Assert.That(fields.Any(f => f.Field == "CitationForm" && f.Kind == RegionFieldKind.Text), Is.True, + "entry-level fields come from the LexEntry layout"); + Assert.That(fields.Any(f => f.Field == "Form" && f.Kind == RegionFieldKind.Text), Is.True, + "the lexeme form crosses into the MoForm object's layout"); + Assert.That(fields.Any(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser + && f.Options.Count >= 2), Is.True, + "the morph-type chooser survives in the composed view with LCModel options"); + + var glossFields = fields.Where(f => f.Field == "Gloss" && f.Kind == RegionFieldKind.Text).ToList(); + Assert.That(glossFields.Count, Is.EqualTo(2), "one gloss row per sense"); + Assert.That(glossFields.Select(f => f.StableId).Distinct().Count(), Is.EqualTo(2), + "each sense's gloss binds its own object"); + Assert.That(glossFields.All(f => f.Indent > 0), Is.True, "sense fields are indented"); + + Assert.That(fields.Any(f => f.Kind == RegionFieldKind.Header && f.Field == "Senses"), Is.True, + "the senses sequence renders a section header"); + } + + // Review finding A: compiled definitions are memoized per (class, layout) for the lifetime + // of the loaded sources — a repeat compose serves every layout from the memo instead of + // rebuilding and re-fingerprinting the ~300KB parts snapshot per object per compose. + [Test] + public void Compose_RepeatCompose_ServesCompiledLayoutsFromTheMemo() + { + Assert.That(FullEntryRegionComposer.Compose(m_entry, Cache), Is.Not.Null, + "priming compose populates the (class, layout) memo"); + var compilesAfterFirst = FullEntryRegionComposer.SnapshotCompileCount; + Assert.That(compilesAfterFirst, Is.GreaterThan(0), "the first compose really compiled"); + + var second = FullEntryRegionComposer.Compose(m_entry, Cache); + Assert.That(second, Is.Not.Null); + Assert.That(second.Model.Fields, Is.Not.Empty, "the memoized models still compose fully"); + Assert.That(FullEntryRegionComposer.SnapshotCompileCount, Is.EqualTo(compilesAfterFirst), + "a repeat compose must not rebuild any layout snapshot"); + } + + [Test] + public void Compose_HidesEmptyIfDataFields_AndShowsThemOnceFilled() + { + var before = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + Assert.That(before.Any(f => f.Field == "Bibliography"), Is.False, + "an empty ifdata field (Bibliography) is hidden, matching legacy"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("Smith 1999", Cache.DefaultAnalWs))); + + var after = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + Assert.That(after.Any(f => f.Field == "Bibliography"), Is.True, + "the field appears once it has data"); + } + + [Test] + public void Edit_NestedSecondSenseGloss_CommitsAsOneGlobalUndoStep() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var secondGloss = composed.Model.Fields + .Where(f => f.Field == "Gloss" && f.Kind == RegionFieldKind.Text) + .Skip(1).First(); + var wsAbbrev = secondGloss.Values[0].WsAbbrev; + + Assert.That(composed.EditContext.TrySetText(secondGloss, wsAbbrev, "edited gloss"), Is.True, + "composed fields stage by their per-object stable id"); + composed.EditContext.Commit(); + + Assert.That(m_entry.SensesOS[1].Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("edited gloss")); + Assert.That(m_entry.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("gloss 1"), + "only the addressed sense changed"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_entry.SensesOS[1].Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("gloss 2"), + "the composed edit is one step on the global undo stack"); + } + + [Test] + public void Compose_ShowHiddenFields_SurfacesNeverAndEmptyIfdataFields() + { + var normal = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + var hidden = FullEntryRegionComposer.Compose(m_entry, Cache, showHiddenFields: true).Model.Fields; + + Assert.That(hidden.Count, Is.GreaterThan(normal.Count), + "show-hidden surfaces strictly more rows, like legacy m_fShowAllFields"); + Assert.That(normal.Any(f => f.Field == "DateCreated"), Is.False, + "visibility=never fields stay hidden by default"); + Assert.That(hidden.Any(f => f.Field == "DateCreated"), Is.True, + "visibility=never fields appear under show-hidden"); + var created = hidden.First(f => f.Field == "DateCreated"); + Assert.That(created.Values[0].Value, Is.Not.Empty, "Time fields render a formatted date"); + Assert.That(hidden.Any(f => f.Field == "Bibliography"), Is.True, + "empty ifdata fields appear under show-hidden"); + } + + // Finding-1 (parity, date): legacy DateSlice renders dt.ToString("f", CurrentUICulture) + // (the full pattern, carrying the day name) — the composer must match exactly. + [Test] + public void Compose_DateCreated_UsesTheLegacyFullDateTimePattern() + { + var hidden = FullEntryRegionComposer.Compose(m_entry, Cache, showHiddenFields: true).Model.Fields; + var created = hidden.First(f => f.Field == "DateCreated"); + + Assert.That(created.Values[0].Value, + Is.EqualTo(m_entry.DateCreated.ToString("f", CultureInfo.CurrentUICulture)), + "Time fields render exactly like legacy DateSlice (\"f\", CurrentUICulture)"); + Assert.That(created.Values[0].Value, + Does.Contain(CultureInfo.CurrentUICulture.DateTimeFormat.GetDayName(m_entry.DateCreated.DayOfWeek)), + "the full pattern carries the day name"); + } + + // Finding-2 (WS spec resolution): the layout ws= spec resolves through the legacy pair — + // WritingSystemServices.GetMagicWsIdFromName then GetWritingSystemList — not substring + // heuristics, so ordering ("analysis vernacular") and list membership match legacy slices. + [TestCase("all analysis")] + [TestCase("all vernacular")] + [TestCase("analysis vernacular")] + [TestCase("vernacular analysis")] + [TestCase("analysis")] + [TestCase("vernacular")] + public void ResolveWritingSystems_MatchesLegacyWritingSystemServices(string spec) + { + var magicId = WritingSystemServices.GetMagicWsIdFromName(spec); + Assert.That(magicId, Is.Not.Zero, "the spec is a legacy magic ws name"); + var expected = WritingSystemServices.GetWritingSystemList(Cache, magicId, forceIncludeEnglish: false); + Assert.That(expected, Is.Not.Empty); + + var actual = FullEntryRegionComposer.ResolveWritingSystems(Cache, spec); + Assert.That(actual.Select(ws => ws.Handle), Is.EqualTo(expected.Select(ws => ws.Handle)), + "the composer resolves ws= specs exactly like legacy SliceFactory/MultiStringSlice"); + } + + [Test] + public void ResolveWritingSystems_Pronunciation_UsesTheProjectPronunciationList() + { + // Point the project pronunciation list at an IPA writing system so it differs from the + // vernacular default — a vernacular-side heuristic cannot pass by coincidence. + var container = Cache.ServiceLocator.WritingSystems; + var saved = container.CurrentPronunciationWritingSystems.ToList(); + CoreWritingSystemDefinition ipa = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("fr-fonipa", out ipa); + container.CurrentPronunciationWritingSystems.Clear(); + container.CurrentPronunciationWritingSystems.Add(ipa); + }); + try + { + var actual = FullEntryRegionComposer.ResolveWritingSystems(Cache, "pronunciation"); + + var expected = WritingSystemServices.GetWritingSystemList(Cache, + WritingSystemServices.kwsPronunciations, forceIncludeEnglish: false); + Assert.That(expected.Select(ws => ws.Handle), Is.EqualTo(new[] { ipa.Handle })); + Assert.That(actual.Select(ws => ws.Handle), Is.EqualTo(expected.Select(ws => ws.Handle)), + "pronunciation specs ride kwsPronunciations (legacy SliceFactory.GetWs lane), not vernacular defaults"); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + container.CurrentPronunciationWritingSystems.Clear(); + foreach (var ws in saved) + container.CurrentPronunciationWritingSystems.Add(ws); + }); + } + } + + [TestCase(null)] + [TestCase("")] + [TestCase("not-a-ws-spec")] + public void ResolveWritingSystems_EmptyOrUnknownSpec_FallsBackToAnalysis(string spec) + { + var expected = WritingSystemServices.GetWritingSystemList(Cache, + WritingSystemServices.kwsAnal, forceIncludeEnglish: false); + var actual = FullEntryRegionComposer.ResolveWritingSystems(Cache, spec); + Assert.That(actual.Select(ws => ws.Handle), Is.EqualTo(expected.Select(ws => ws.Handle)), + "unmarked/unknown specs take GetWritingSystemList's analysis default, like legacy"); + } + + // Finding-3: the Abbreviation is user-editable and can collide across writing systems; + // composition must still succeed (no ToDictionary crash) and edits must route by the + // unique IETF tag, never to the wrong alternative. + [Test] + public void Compose_DuplicateWsAbbreviations_StillComposes_AndEditsRouteByWsTag() + { + var container = Cache.ServiceLocator.WritingSystems; + var defaultAnal = container.DefaultAnalysisWritingSystem; + CoreWritingSystemDefinition second = null; + string originalAbbrev = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + Cache.ServiceLocator.WritingSystemManager.GetOrSet("es", out second); + container.AddToCurrentAnalysisWritingSystems(second); + originalAbbrev = second.Abbreviation; + second.Abbreviation = defaultAnal.Abbreviation; // collide on purpose + }); + try + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + Assert.That(composed, Is.Not.Null, "duplicate abbreviations must not abort composition"); + var gloss = composed.Model.Fields.First(f => f.Field == "Gloss" && f.Kind == RegionFieldKind.Text); + Assert.That(gloss.Values.Count, Is.EqualTo(2), "one row per current analysis writing system"); + var secondRow = gloss.Values.Single(v => v.WsTag == second.Id); + + Assert.That(composed.EditContext.TrySetText(gloss, secondRow.WsTag, "glosa"), Is.True, + "the second row's tag addresses its own alternative"); + composed.EditContext.Commit(); + + Assert.That(m_entry.SensesOS[0].Gloss.get_String(second.Handle).Text, Is.EqualTo("glosa"), + "the edit lands on the writing system addressed by tag"); + Assert.That(m_entry.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("gloss 1"), + "the alternative sharing the abbreviation is untouched"); + Cache.ActionHandlerAccessor.Undo(); + } + finally + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + second.Abbreviation = originalAbbrev; + container.AnalysisWritingSystems.Remove(second); + container.CurrentAnalysisWritingSystems.Remove(second); + }); + } + } + + [Test] + public void Compose_BooleanFields_RenderAsCheckboxKind_AndToggle() + { + var hidden = FullEntryRegionComposer.Compose(m_entry, Cache, showHiddenFields: true); + var boolField = hidden.Model.Fields.FirstOrDefault(f => f.Kind == RegionFieldKind.Boolean); + Assert.That(boolField, Is.Not.Null, "the entry layout carries at least one checkbox field"); + var original = boolField.SelectedOptionKey; + + Assert.That(hidden.EditContext.TrySetOption(boolField, original == "true" ? "false" : "true"), Is.True); + hidden.EditContext.Cancel(); // viewing test: roll the toggle back + } + + [Test] + public void Compose_SenseHeaders_UseHierarchicalNumbering_WithGlossSummary() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var sub = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].SensesOS.Add(sub); + sub.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("subgloss", Cache.DefaultAnalWs)); + }); + + var fields = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + var headers = fields.Where(f => f.Kind == RegionFieldKind.Header).Select(f => f.Label).ToList(); + + Assert.That(headers.Any(h => h.StartsWith("1 ") || h.StartsWith("1 ") || h.StartsWith("1 ", System.StringComparison.Ordinal) || h.StartsWith("1")), Is.True, + "top senses are numbered: " + string.Join(" | ", headers)); + Assert.That(headers.Any(h => h.StartsWith("1.1")), Is.True, + "subsenses use hierarchical numbers (1.1): " + string.Join(" | ", headers)); + Assert.That(headers.Any(h => h.Contains("gloss 1")), Is.True, + "sense headers carry the gloss summary like legacy sense lines"); + Assert.That(fields.First(f => f.Label != null && f.Label.StartsWith("1.1")).IsCollapsible, Is.True, + "sense items are collapsible"); + } + + [Test] + public void Edit_MorphType_InComposedView_Commits() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var morphType = composed.Model.Fields.Single(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser); + var target = morphType.Options.First(o => o.Key != morphType.SelectedOptionKey); + + Assert.That(composed.EditContext.TrySetOption(morphType, target.Key), Is.True); + composed.EditContext.Commit(); + Assert.That(m_entry.LexemeFormOA.MorphTypeRA.Guid.ToString(), Is.EqualTo(target.Key)); + } + + // Review round 2: legacy MorphTypeAtomicLauncher gates stem<->affix morph-type swaps behind a + // data-loss prompt AND an allomorph class conversion. Until that class-conversion lane lands, + // the composed chooser must reject a boundary-crossing assignment instead of creating a + // model-invalid stem-allomorph-with-affix-type. + [Test] + public void Edit_MorphType_StemAllomorphWithAffixType_IsRejected() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var morphType = composed.Model.Fields.Single(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser); + var before = m_entry.LexemeFormOA.MorphTypeRA; + + Assert.That(composed.EditContext.TrySetOption(morphType, + MoMorphTypeTags.kguidMorphSuffix.ToString()), Is.False, + "a suffix morph type on an IMoStemAllomorph crosses the stem/affix boundary"); + Assert.That(m_entry.LexemeFormOA.MorphTypeRA, Is.EqualTo(before), "nothing was assigned"); + + Assert.That(composed.EditContext.TrySetOption(morphType, + MoMorphTypeTags.kguidMorphRoot.ToString()), Is.True, + "same-side (stem-type) assignments still work"); + composed.EditContext.Cancel(); + } + + // 14.1 — ghost rows: the legacy "Click here to add ..." line is an editable watermark row; + // typing creates the missing object inside the fenced session (one undoable step) and routes + // the text into the layout's ghost field (ghost=/ghostWs=). + [Test] + public void Compose_GhostLexemeForm_CreatesTheAllomorph_OnFirstEdit() + { + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + bare = Cache.ServiceLocator.GetInstance().Create()); + + var composed = FullEntryRegionComposer.Compose(bare, Cache); + var ghost = composed.Model.Fields.FirstOrDefault(f => + f.Field == "LexemeForm" && f.StableId.EndsWith("/ghost")); + Assert.That(ghost, Is.Not.Null, "an empty lexeme form renders the legacy ghost line"); + Assert.That(ghost.GhostPrompt, Is.Not.Null.And.Not.Empty, "the prompt rides as a watermark"); + Assert.That(ghost.IsEditable, Is.True, "the ghost row accepts typing"); + Assert.That(ghost.Values[0].Value, Is.Empty, "the prompt is never field content"); + + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "casa"), Is.True); + composed.EditContext.Commit(); + + Assert.That(bare.LexemeFormOA, Is.Not.Null, "typing created the missing object (ghost= lane)"); + Assert.That(bare.LexemeFormOA, Is.InstanceOf(), + "abstract MoForm defaults to a stem allomorph, like legacy CreateAllomorph"); + Assert.That(bare.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("casa"), + "the typed text landed in the ghost field (Form, ghostWs=vernacular)"); + } + + // Review round 2: the ghost setter's closure caches the created object's hvo, but a Cancel + // rolls the MakeNewObject back — a later edit through the SAME still-visible view must + // re-create the object instead of writing to the deleted hvo (which throws). + [Test] + public void Compose_GhostEdit_AfterCancel_ReCreatesInsteadOfWritingToTheDeletedObject() + { + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + bare = Cache.ServiceLocator.GetInstance().Create()); + + var composed = FullEntryRegionComposer.Compose(bare, Cache); + var ghost = composed.Model.Fields.First(f => + f.Field == "LexemeForm" && f.StableId.EndsWith("/ghost")); + + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "casa"), Is.True); + composed.EditContext.Cancel(); + Assert.That(bare.LexemeFormOA, Is.Null, "cancel rolled the created allomorph back"); + + Assert.That(() => composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "gato"), + Throws.Nothing, "the cached hvo is stale after the rollback; the setter must notice"); + composed.EditContext.Commit(); + + Assert.That(bare.LexemeFormOA, Is.Not.Null, "typing again re-created the object"); + Assert.That(bare.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text, Is.EqualTo("gato")); + } + + [Test] + public void Compose_GhostSenses_CreateASense_WithTheTypedGloss() + { + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + bare = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + bare.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + }); + + var composed = FullEntryRegionComposer.Compose(bare, Cache, showHiddenFields: true); + var ghost = composed.Model.Fields.FirstOrDefault(f => + f.Field == "Senses" && f.StableId.EndsWith("/ghost")); + Assert.That(ghost, Is.Not.Null, "an entry without senses renders the add-a-sense ghost"); + Assert.That(ghost.IsEditable, Is.True); + + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "house"), Is.True); + composed.EditContext.Commit(); + + Assert.That(bare.SensesOS.Count, Is.EqualTo(1), "typing created the sense"); + Assert.That(bare.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("house"), + "the typed text became the gloss (the LexSense ghost default)"); + } + + // B2 (xml-retirement-blockers) — ghost metadata generality: the shipped lexeme-form ghost + // (LexEntryParts.xml LexEntry-Detail-LexemeForm) carries an explicit ghostClass + // ("MoStemAllomorph", differing from the abstract MoForm field signature) AND + // ghostInitMethod="SetMorphTypeToRoot". The composer must create the configured class and + // invoke the init hook by reflection after the typed text lands, inside the same session — + // exactly GhostStringSliceView.MakeRealObject (GhostStringSlice.cs:279-329). + [Test] + public void Compose_GhostLexemeForm_HonorsGhostClass_AndRunsGhostInitMethod() + { + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + bare = Cache.ServiceLocator.GetInstance().Create()); + + var composed = FullEntryRegionComposer.Compose(bare, Cache); + var ghost = composed.Model.Fields.First(f => + f.Field == "LexemeForm" && f.StableId.EndsWith("/ghost")); + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "casa"), Is.True); + composed.EditContext.Commit(); + + Assert.That(bare.LexemeFormOA, Is.InstanceOf(), + "ghostClass=MoStemAllomorph picks the concrete class for the abstract MoForm signature"); + var morphType = ((IMoStemAllomorph)bare.LexemeFormOA).MorphTypeRA; + Assert.That(morphType, Is.Not.Null, + "ghostInitMethod=SetMorphTypeToRoot must run after creation (B2; was dropped by 14.1)"); + Assert.That(morphType.Guid, Is.EqualTo(MoMorphTypeTags.kguidMorphRoot), + "SetMorphTypeToRoot assigns the root morph type, like legacy MakeRealObject's reflection hook"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(bare.LexemeFormOA, Is.Null, + "creation + text + init method are ONE step on the global undo stack, like the legacy UOW"); + } + + // B2 — the Translations ghost (LexExampleSentence-Detail-TranslationsAllA): no ghostClass + // (the concrete CmTranslation comes from the field signature), ghostWs="analysis", and + // ghostInitMethod="SetTypeToFreeTrans" must type the new translation as Free Translation. + [Test] + public void Compose_GhostTranslation_CreatesACmTranslation_TypedFreeByGhostInitMethod() + { + ILexExampleSentence example = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + example = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].ExamplesOS.Add(example); + example.Example.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("una casa", Cache.DefaultVernWs)); + }); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var ghost = composed.Model.Fields.FirstOrDefault(f => + f.Field == "Translations" && f.StableId.EndsWith("/ghost") && f.ObjectHvo == example.Hvo); + Assert.That(ghost, Is.Not.Null, + "an example without translations renders the translation ghost row"); + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "a house"), Is.True); + composed.EditContext.Commit(); + + Assert.That(example.TranslationsOC.Count, Is.EqualTo(1), + "typing created the CmTranslation (class from the field signature, no ghostClass)"); + var translation = example.TranslationsOC.First(); + Assert.That(translation.Translation.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("a house"), + "ghostWs=analysis routes the text into the analysis alternative"); + Assert.That(translation.TypeRA, Is.Not.Null, "ghostInitMethod=SetTypeToFreeTrans ran"); + Assert.That(translation.TypeRA.Guid, Is.EqualTo(CmPossibilityTags.kguidTranFreeTranslation), + "the new translation is typed Free Translation, like legacy"); + } + + // B3 (xml-retirement-blockers) — conditional display: the real shipped variant/complex-form + // divergence. LexEntryRef/Normal's VariantEntryTypes and ComplexEntryTypes parts are + // twins (LexEntryParts.xml:1133-1162); exactly one may + // compose per record state. Before B3 both were dropped (conditional-dropped). + [Test] + public void Compose_EntryRefConditionals_VariantAndComplexForm_ComposeDifferently() + { + ILexEntryRef entryRef = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + entryRef = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(entryRef); + entryRef.RefType = LexEntryRefTags.krtVariant; // RefType == 0 + }); + + var variantFields = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + Assert.That(variantFields.Any(f => f.Field == "VariantEntryTypes"), Is.True, + "a variant ref (RefType=0) composes the Variant Type slice"); + Assert.That(variantFields.Any(f => f.Field == "ComplexEntryTypes"), Is.False, + "the complex-form twin's intequals=1 condition fails for a variant ref"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + entryRef.RefType = LexEntryRefTags.krtComplexForm); // RefType == 1 + + var complexFields = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + Assert.That(complexFields.Any(f => f.Field == "ComplexEntryTypes"), Is.True, + "a complex-form ref (RefType=1) composes the Complex Form Type slice"); + Assert.That(complexFields.Any(f => f.Field == "VariantEntryTypes"), Is.False, + "the variant twin's intequals=0 condition fails for a complex-form ref"); + } + + // B3 — lengthatleast: LexEntry-Detail-ShowMinorEntry wraps the PublishAsMinorEntry checkbox + // in — main entries (no refs) must not show it. + [Test] + public void Compose_ShowMinorEntry_OnlyWhenTheEntryHasEntryRefs() + { + var before = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + Assert.That(before.Any(f => f.Field == "PublishAsMinorEntry"), Is.False, + "a main entry without EntryRefs hides Show Minor Entry, like legacy"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var entryRef = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(entryRef); + entryRef.RefType = LexEntryRefTags.krtVariant; + }); + + var after = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + var row = after.FirstOrDefault(f => f.Field == "PublishAsMinorEntry"); + Assert.That(row, Is.Not.Null, "with an EntryRef the lengthatleast=1 condition passes"); + Assert.That(row.Kind, Is.EqualTo(RegionFieldKind.Boolean), "it renders as the legacy checkbox"); + } + + // B3 — /: MoAffixAllomorph-Detail-AsPosition shows the infix + // position slice only for infix (and infixing-interfix) morph types; no branch passes for a + // prefix, so nothing renders (the shipped choice has no otherwise). + [Test] + public void Compose_InfixPosition_ChoiceWhereGuidEquals_FollowsTheMorphType() + { + ILexEntry affixEntry = null; + IMoAffixAllomorph allomorph = null; + var morphTypes = Cache.ServiceLocator.GetInstance(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + affixEntry = Cache.ServiceLocator.GetInstance().Create(); + allomorph = Cache.ServiceLocator.GetInstance().Create(); + affixEntry.LexemeFormOA = allomorph; + allomorph.Form.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("infixo", Cache.DefaultVernWs)); + allomorph.MorphTypeRA = morphTypes.GetObject(MoMorphTypeTags.kguidMorphInfix); + }); + + var infixFields = FullEntryRegionComposer.Compose(affixEntry, Cache).Model.Fields; + Assert.That(infixFields.Any(f => f.Field == "Position"), Is.True, + "an infix allomorph composes the Infix Positions slice (guidequals where passes)"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + allomorph.MorphTypeRA = morphTypes.GetObject(MoMorphTypeTags.kguidMorphPrefix)); + + var prefixFields = FullEntryRegionComposer.Compose(affixEntry, Cache).Model.Fields; + Assert.That(prefixFields.Any(f => f.Field == "Position"), Is.False, + "no where clause passes for a prefix, and the shipped choice has no otherwise"); + } + + // B3 — target="owner" + lengthatleast: MoAffixAllomorph-Detail-MsEnvFeaturesForLexemeForm is + // — the test reads the + // ENTRY's MSA count from the allomorph's row, so data on the allomorph alone must not show it. + [Test] + public void Compose_MsEnvFeatures_TargetOwnerCondition_ReadsTheOwningEntry() + { + ILexEntry affixEntry = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + affixEntry = Cache.ServiceLocator.GetInstance().Create(); + var allomorph = Cache.ServiceLocator.GetInstance().Create(); + affixEntry.LexemeFormOA = allomorph; + allomorph.Form.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("supfix", Cache.DefaultVernWs)); + allomorph.MorphTypeRA = Cache.ServiceLocator.GetInstance() + .GetObject(MoMorphTypeTags.kguidMorphSuffix); + // Data in the conditioned field itself — visible only when the CONDITION passes. + allomorph.MsEnvFeaturesOA = Cache.ServiceLocator.GetInstance().Create(); + }); + + var withoutMsa = FullEntryRegionComposer.Compose(affixEntry, Cache).Model.Fields; + Assert.That(withoutMsa.Any(f => f.Field == "MsEnvFeatures"), Is.False, + "no MSA on the owning entry: the target=owner lengthatleast=1 condition fails " + + "even though the field itself has data"); + + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + affixEntry.MorphoSyntaxAnalysesOC.Add( + Cache.ServiceLocator.GetInstance().Create())); + + var withMsa = FullEntryRegionComposer.Compose(affixEntry, Cache).Model.Fields; + Assert.That(withMsa.Any(f => f.Field == "MsEnvFeatures"), Is.True, + "with an MSA the owner-hop condition passes and the Required Features row composes"); + } + + // Section 13.2 — composed rows carry the legacy menu bindings from the live shipped layouts + // and the hvo of the object that owns them, so the host can show the same xCore menu and + // point command routing at the right object. + [Test] + public void Compose_ThreadsLegacyMenuBindings_AndOwningObjectHvos() + { + var fields = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + + var citation = fields.First(f => f.Field == "CitationForm" && f.Kind == RegionFieldKind.Text); + Assert.That(citation.MenuId, Is.EqualTo("mnuDataTree-Help"), + "the slice menu from LexEntryParts.xml CitationFormAllV rides the composed row"); + Assert.That(citation.ContextMenuId, Is.EqualTo("mnuDataTree-CitationFormContext"), + "the in-string contextMenu binding rides the composed row"); + Assert.That(citation.ObjectHvo, Is.EqualTo(m_entry.Hvo), + "entry-level rows bind the entry"); + + var gloss = fields.First(f => f.Field == "Gloss" && f.Kind == RegionFieldKind.Text); + Assert.That(gloss.ObjectHvo, Is.EqualTo(m_entry.SensesOS[0].Hvo), + "sense rows bind their own sense so commands (Delete Sense, etc.) target it"); + + Assert.That(fields.Count(f => !string.IsNullOrEmpty(f.MenuId)), Is.GreaterThan(3), + "menu bindings are pervasive in the shipped layouts, not a one-off"); + } + + // 15.3 — sense item headers inherit the sense layout's root binding (LexSense.fwlayout's + // HeavySummary part ref carries menu="mnuDataTree-Sense"); the Senses sequence node itself + // has no menu attribute, so without inheritance right-click could never offer Insert Sense. + [Test] + public void Compose_SenseHeaders_BindTheSenseMenu_WithInsertSenseDefined() + { + var fields = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields; + var senseHeader = fields.First(f => f.Kind == RegionFieldKind.Header + && f.Field == "Senses" && f.ObjectHvo == m_entry.SensesOS[0].Hvo); + + Assert.That(senseHeader.MenuId, Is.EqualTo("mnuDataTree-Sense"), + "the per-sense header carries the legacy sense slice menu"); + Assert.That(senseHeader.HotlinksId, Is.EqualTo("mnuDataTree-Sense-Hotlinks")); + + // And the shipped definition of that menu offers the add-sense commands, in order. + var includePath = System.IO.Path.Combine( + SIL.FieldWorks.Common.FwUtils.FwDirectoryFinder.GetCodeSubDirectory( + @"Language Explorer\Configuration\Lexicon"), + "DataTreeInclude.xml"); + var menu = System.Xml.Linq.XDocument.Load(includePath).Descendants("menu") + .First(m => (string)m.Attribute("id") == "mnuDataTree-Sense"); + var commands = menu.Elements("item") + .Select(i => (string)i.Attribute("command")) + .Where(c => !string.IsNullOrEmpty(c)) + .ToList(); + Assert.That(commands, Does.Contain("CmdDataTree-Insert-SenseBelow"), + "Insert Sense is reachable from the sense header right-click"); + Assert.That(commands, Does.Contain("CmdDataTree-Insert-SubSense")); + Assert.That(commands, Does.Contain("CmdDataTree-Delete-Sense")); + } + + // Section 13.6 — every menu id the composer emits (plus the ids the host always appends, + // matching legacy DTMenuHandler.ShowSliceContextMenu) must resolve to a definition + // in the shipped window configuration, so XWindow.ShowContextMenu can materialize it. + [Test] + public void Compose_EveryMenuBinding_ResolvesInTheShippedWindowConfiguration() + { + var fields = FullEntryRegionComposer.Compose(m_entry, Cache, showHiddenFields: true).Model.Fields; + var composedIds = fields + .SelectMany(f => new[] { f.MenuId, f.ContextMenuId, f.HotlinksId }) + .Where(id => !string.IsNullOrEmpty(id)) + .Concat(new[] { "mnuDataTree-Object", "mnuDataTree-MultiStringSlice" }) + .Distinct() + .ToList(); + Assert.That(composedIds.Count, Is.GreaterThan(2), "the composed entry carries menu bindings"); + + var configurationDir = SIL.FieldWorks.Common.FwUtils.FwDirectoryFinder + .GetCodeSubDirectory(@"Language Explorer\Configuration"); + var definedIds = System.IO.Directory + .GetFiles(configurationDir, "*.xml", System.IO.SearchOption.AllDirectories) + .SelectMany(file => + { + try { return System.Xml.Linq.XDocument.Load(file).Descendants("menu"); } + catch (System.Xml.XmlException) { return Enumerable.Empty(); } + }) + .Select(menu => (string)menu.Attribute("id")) + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(); + + Assert.That(composedIds.Where(id => !definedIds.Contains(id)), Is.Empty, + "every composed menu id must be materializable by XWindow.ShowContextMenu"); + } + } + + /// + /// B1 (xml-retirement-blockers, task 9.5) — custom fields must not vanish from the composed + /// view: the `<part customFields="here"/>` placeholder expands from live MDC metadata the + /// way legacy DataTree.EnsureCustomFields injects a generated `<part ref="Custom"/>` per + /// custom field of the object's class (and base classes) and SliceFactory.MakeAutoCustomSlice + /// realizes the editor by CellarPropertyType with the field's Userlabel. The generated part + /// carries no visibility attribute, so custom fields are visibility=always in legacy — they + /// show even when empty, with or without "show hidden fields" (DataTree.cs:2435). + /// + [TestFixture] + public class FullEntryRegionComposerCustomFieldTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + private GenDate m_genDate; + private IMoMorphType m_listItem; + private bool m_fieldsCreated; + private int m_flidEntryMulti; + private int m_flidEntrySingle; + private int m_flidEntryDate; + private int m_flidEntryListRef; + private int m_flidEntryNumber; + private int m_flidSenseSingle; + + public override void TestSetup() + { + base.TestSetup(); + EnsureCustomFields(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + sense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("house", Cache.DefaultAnalWs)); + + var sda = Cache.DomainDataByFlid; + sda.SetMultiStringAlt(m_entry.Hvo, m_flidEntryMulti, Cache.DefaultAnalWs, + TsStringUtils.MakeString("high-low", Cache.DefaultAnalWs)); + sda.SetMultiStringAlt(m_entry.Hvo, m_flidEntryMulti, Cache.DefaultVernWs, + TsStringUtils.MakeString("alto-bajo", Cache.DefaultVernWs)); + sda.SetString(m_entry.Hvo, m_flidEntrySingle, + TsStringUtils.MakeString("from Smith", Cache.DefaultAnalWs)); + m_genDate = new GenDate(GenDate.PrecisionType.Approximate, 3, 14, 2020, true); + ((ISilDataAccessManaged)sda).SetGenDate(m_entry.Hvo, m_flidEntryDate, m_genDate); + m_listItem = Cache.LangProject.LexDbOA.MorphTypesOA.ReallyReallyAllPossibilities + .OfType().First(); + sda.SetObjProp(m_entry.Hvo, m_flidEntryListRef, m_listItem.Hvo); + sda.SetInt(m_entry.Hvo, m_flidEntryNumber, 42); + sda.SetString(sense.Hvo, m_flidSenseSingle, + TsStringUtils.MakeString("sense note", Cache.DefaultAnalWs)); + }); + } + + // The fixture cache is shared across tests (MemoryOnlyBackendProviderTestBase), so the + // custom fields are created once — re-running UpdateCustomField per test would mint + // duplicate fields. Created exactly the way AddCustomFieldDlg/legacy tests do. + private void EnsureCustomFields() + { + if (m_fieldsCreated) + return; + m_fieldsCreated = true; + m_flidEntryMulti = MakeCustomField("Tone Pattern", LexEntryTags.kClassId, + CellarPropertyType.MultiUnicode, WritingSystemServices.kwsAnalVerns); + m_flidEntrySingle = MakeCustomField("Source Note", LexEntryTags.kClassId, + CellarPropertyType.String, WritingSystemServices.kwsAnal); + m_flidEntryDate = MakeCustomField("Date Collected", LexEntryTags.kClassId, + CellarPropertyType.GenDate, 0); + m_flidEntryListRef = MakeCustomField("Field Category", LexEntryTags.kClassId, + CellarPropertyType.ReferenceAtomic, 0, CmPossibilityTags.kClassId, + Cache.LangProject.LexDbOA.MorphTypesOA.Guid); + m_flidEntryNumber = MakeCustomField("Frequency Count", LexEntryTags.kClassId, + CellarPropertyType.Integer, 0); + m_flidSenseSingle = MakeCustomField("Sense Source", LexSenseTags.kClassId, + CellarPropertyType.String, WritingSystemServices.kwsAnal); + } + + private int MakeCustomField(string userLabel, int classId, CellarPropertyType type, + int wsSelector, int dstCls = 0, System.Guid listRootId = default(System.Guid)) + { + var fd = new FieldDescription(Cache) + { + Userlabel = userLabel, + HelpString = string.Empty, + Class = classId, + Type = type, + WsSelector = wsSelector, + DstCls = dstCls, + ListRootId = listRootId + }; + fd.UpdateCustomField(); + return fd.Id; + } + + private System.Collections.Generic.IReadOnlyList Compose(bool showHidden = false) + => FullEntryRegionComposer.Compose(m_entry, Cache, showHidden).Model.Fields; + + [Test] + public void Compose_CustomFields_ExpandAtThePlaceholder_WithLegacyLabelsAndValues() + { + var fields = Compose(); + + // Multistring: an editable text row, one value per ws of the field's WsSelector. + var multi = fields.FirstOrDefault(f => f.Label == "Tone Pattern"); + Assert.That(multi, Is.Not.Null, "the custom multistring expands at the placeholder"); + Assert.That(multi.Kind, Is.EqualTo(RegionFieldKind.Text)); + Assert.That(multi.IsEditable, Is.True); + Assert.That(multi.ObjectHvo, Is.EqualTo(m_entry.Hvo), "entry-level custom rows bind the entry"); + var expectedWs = FullEntryRegionComposer.ResolveWritingSystems(Cache, "analysis vernacular"); + Assert.That(multi.Values.Count, Is.EqualTo(expectedWs.Count), + "kwsAnalVerns yields one row per analysis+vernacular ws, like legacy MultiStringSlice"); + Assert.That(multi.Values.Select(v => v.Value), Does.Contain("high-low").And.Contain("alto-bajo")); + + // Single string: one editable row in the selector's default ws. + var single = fields.FirstOrDefault(f => f.Label == "Source Note"); + Assert.That(single, Is.Not.Null); + Assert.That(single.IsEditable, Is.True); + Assert.That(single.Values.Single().Value, Is.EqualTo("from Smith")); + + // GenDate: read-only, formatted exactly like the existing GenDate rows. + var date = fields.FirstOrDefault(f => f.Label == "Date Collected"); + Assert.That(date, Is.Not.Null); + Assert.That(date.IsEditable, Is.False, "GenDate stays read-only (matches existing GenDate rows)"); + Assert.That(date.Values.Single().Value, Is.EqualTo(m_genDate.ToLongString())); + + // Possibility-list reference: read-only joined name for now (chooser write-back rides 6.3). + var listRef = fields.FirstOrDefault(f => f.Label == "Field Category"); + Assert.That(listRef, Is.Not.Null); + Assert.That(listRef.IsEditable, Is.False, "reference write-back is deferred to the 6.3 chooser lane"); + Assert.That(listRef.Values.Single().Value, Is.EqualTo(m_listItem.ShortName)); + + // Integer: editable like the existing int rows. + var number = fields.FirstOrDefault(f => f.Label == "Frequency Count"); + Assert.That(number, Is.Not.Null); + Assert.That(number.IsEditable, Is.True); + Assert.That(number.Values.Single().Value, Is.EqualTo("42")); + + // The sense-level custom field rides the sense's own placeholder, bound to the sense. + var senseField = fields.FirstOrDefault(f => f.Label == "Sense Source"); + Assert.That(senseField, Is.Not.Null, "LexSense custom fields expand inside the sense block"); + Assert.That(senseField.ObjectHvo, Is.EqualTo(m_entry.SensesOS[0].Hvo)); + Assert.That(senseField.Values.Single().Value, Is.EqualTo("sense note")); + var glossIndex = fields.ToList().FindIndex(f => f.Field == "Gloss" && f.ObjectHvo == m_entry.SensesOS[0].Hvo); + var senseFieldIndex = fields.ToList().IndexOf(senseField); + Assert.That(senseFieldIndex, Is.GreaterThan(glossIndex), + "the sense placeholder sits after the authored sense fields"); + } + + [Test] + public void Compose_CustomFields_SitAtTheLegacyPlaceholderPosition() + { + // The LexEntry placeholder sits after the authored entry fields (CitationForm etc.) and + // before the trailing DateCreated/DateModified never-fields. + var fields = Compose(showHidden: true).ToList(); + var citationIndex = fields.FindIndex(f => f.Field == "CitationForm"); + var customIndex = fields.FindIndex(f => f.Label == "Source Note"); + var dateCreatedIndex = fields.FindIndex(f => f.Field == "DateCreated" && f.ObjectHvo == m_entry.Hvo); + + Assert.That(customIndex, Is.GreaterThan(citationIndex), + "entry custom rows come after the authored entry fields"); + Assert.That(customIndex, Is.LessThan(dateCreatedIndex), + "entry custom rows come before the trailing never-visibility fields, like the layout's placeholder"); + + // Creation order (the MDC enumeration legacy FieldDescription.FieldDescriptors walks). + var multiIndex = fields.FindIndex(f => f.Label == "Tone Pattern"); + var dateIndex = fields.FindIndex(f => f.Label == "Date Collected"); + Assert.That(multiIndex, Is.LessThan(customIndex), "custom rows keep field-creation order"); + Assert.That(customIndex, Is.LessThan(dateIndex), "custom rows keep field-creation order"); + } + + [Test] + public void Edit_CustomFields_StageThroughTheFencedSession_AsOneUndoStep() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var multi = composed.Model.Fields.First(f => f.Label == "Tone Pattern"); + var single = composed.Model.Fields.First(f => f.Label == "Source Note"); + + // Address the analysis alternative by its own WS tag: the row order follows the field's + // WsSelector resolution (legacy GetWritingSystemList), so Values[0] need not be analysis. + var analTag = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id; + Assert.That(multi.Values.Select(v => v.WsTag), Does.Contain(analTag), + "the multistring row carries the analysis alternative"); + Assert.That(composed.EditContext.TrySetText(multi, analTag, "low-high"), Is.True, + "custom text rows stage through the same setter registry as authored fields"); + Assert.That(composed.EditContext.TrySetText(single, single.Values[0].WsTag, "from Jones"), Is.True); + composed.EditContext.Commit(); + + var sda = Cache.DomainDataByFlid; + Assert.That(sda.get_MultiStringAlt(m_entry.Hvo, m_flidEntryMulti, Cache.DefaultAnalWs).Text, + Is.EqualTo("low-high")); + Assert.That(sda.get_StringProp(m_entry.Hvo, m_flidEntrySingle).Text, Is.EqualTo("from Jones")); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(sda.get_MultiStringAlt(m_entry.Hvo, m_flidEntryMulti, Cache.DefaultAnalWs).Text, + Is.EqualTo("high-low"), "one undo reverts the whole session, both custom edits included"); + Assert.That(sda.get_StringProp(m_entry.Hvo, m_flidEntrySingle).Text, Is.EqualTo("from Smith")); + } + + [Test] + public void Compose_EmptyCustomFields_StayVisible_LikeLegacyAlwaysVisibility() + { + // Legacy generated custom part refs carry no visibility attribute -> "always": an empty + // custom field still shows its (blank) row, with or without "show hidden fields". + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + bare = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + bare.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("gato", Cache.DefaultVernWs)); + }); + + foreach (var showHidden in new[] { false, true }) + { + var fields = FullEntryRegionComposer.Compose(bare, Cache, showHidden).Model.Fields; + foreach (var label in new[] { "Tone Pattern", "Source Note", "Date Collected", "Field Category" }) + { + var row = fields.FirstOrDefault(f => f.Label == label); + Assert.That(row, Is.Not.Null, + $"empty custom field '{label}' must stay visible (showHidden={showHidden}), like legacy visibility=always"); + Assert.That(row.Values.All(v => string.IsNullOrEmpty(v.Value)), Is.True); + } + } + } + + [Test] + public void Compose_CustomFields_AreNotDuplicated_AcrossRepeatComposes() + { + var first = Compose(); + var second = Compose(); + Assert.That(second.Count(f => f.Label == "Source Note"), + Is.EqualTo(first.Count(f => f.Label == "Source Note")).And.EqualTo(1), + "each custom field renders exactly one row per object per compose"); + } + } + + /// Task 3.14 — the cross-surface DnD payloads round-trip through OS data objects. + [TestFixture] + public class FwDragDropDataTests : MemoryOnlyBackendProviderTestBase + { + [Test] + public void RecordDrag_RoundTrips_ToTheSameObject() + { + ILexEntry entry = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + entry = Cache.ServiceLocator.GetInstance().Create()); + + var dataObject = FwDragDropData.CreateRecordDataObject(entry); + Assert.That(FwDragDropData.TryGetRecord(dataObject, Cache, out var resolved), Is.True); + Assert.That(resolved, Is.SameAs(entry)); + Assert.That(dataObject.GetData(System.Windows.Forms.DataFormats.UnicodeText), Is.Not.Null, + "external drop targets get a plain-text label"); + } + + [Test] + public void NonRecordData_DoesNotResolve() + { + var dataObject = new System.Windows.Forms.DataObject(System.Windows.Forms.DataFormats.UnicodeText, "just text"); + Assert.That(FwDragDropData.TryGetRecord(dataObject, Cache, out _), Is.False); + } + + [Test] + public void TextDrag_CarriesTheSameDualLanePayloadAsTheClipboard() + { + var clipboard = new FwTsStringClipboard(Cache.WritingSystemFactory); + var payload = clipboard.FromTsString(TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + + var dataObject = FwDragDropData.CreateTextDataObject(payload); + Assert.That(dataObject.GetDataPresent(SIL.FieldWorks.Common.RootSites.TsStringWrapper.TsStringFormat), Is.True, + "text drags carry the legacy rich lane"); + Assert.That(dataObject.GetData(System.Windows.Forms.DataFormats.UnicodeText), Is.EqualTo("casa")); + } + } +} diff --git a/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs b/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs new file mode 100644 index 0000000000..b02d5fefb8 --- /dev/null +++ b/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs @@ -0,0 +1,130 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The hybrid companion lane for the LexEntry "Messages" slice (Chorus Send/Receive notes bar): + /// the composer carries the legacy custom-editor identity (class/assembly, keyed by the + /// placeholder row's StableId) instead of dropping it, the designated-class selection picks the + /// Messages slice for promotion, and the model filter removes exactly the promoted rows so the + /// Avalonia region no longer shows the grey unsupported placeholder. The WinForms/Chorus half + /// (PocWinFormsHostControl.SetCompanionControls + the real MessageSlice) is manual-verification + /// territory — headless UI for the Chorus notes bar is impractical. + /// + [TestFixture] + public class FullEntryRegionMessagesCompanionTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + }); + } + + [Test] + public void Compose_CarriesTheMessagesSliceCustomEditorIdentity_KeyedToItsPlaceholderRow() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + Assert.That(composed, Is.Not.Null, "the shipped layouts must compose"); + + var messages = composed.CustomEditorFields + .Where(f => f.ClassName == AvaloniaCompanionSlices.MessageSliceClassName) + .ToList(); + Assert.That(messages.Count, Is.EqualTo(1), + "LexEntry/Normal reaches the Messages part (LexEntry-Detail-Messages) exactly once"); + + var binding = messages[0]; + Assert.That(binding.AssemblyPath, Is.EqualTo("LexEdDll.dll"), + "the layout's assemblyPath rides along for DynamicLoader"); + Assert.That(binding.ObjectHvo, Is.EqualTo(m_entry.Hvo), + "the Messages slice binds the entry itself (field='Self')"); + + // StableId coordination: the binding keys exactly one row in the composed model — the + // placeholder rendering the companion lane replaces (the slice's field='Self' resolves + // to a reference-atomic flid, so the composer renders a read-only row, never an editor). + var rows = composed.Model.Fields.Where(f => f.StableId == binding.FieldStableId).ToList(); + Assert.That(rows.Count, Is.EqualTo(1), "the binding's StableId addresses exactly one row"); + Assert.That(rows[0].IsEditable, Is.False, "the placeholder row is never an editor"); + Assert.That(rows[0].EditorClassification, Is.EqualTo(EditorClassification.Dynamic), + "editor='Custom' classifies as a dynamically loaded editor"); + } + + [Test] + public void SelectPromotions_PicksOnlyDesignatedCompanionClasses() + { + var messages = new ComposedCustomEditorField("id1", + AvaloniaCompanionSlices.MessageSliceClassName, "LexEdDll.dll", "Messages", 17); + var other = new ComposedCustomEditorField("id2", + "SIL.FieldWorks.XWorks.LexEd.GhostLexRefSlice", "LexEdDll.dll", "Components", 17); + + var promotions = AvaloniaCompanionSlices.SelectPromotions( + new[] { other, messages, null }); + + Assert.That(promotions.Select(p => p.FieldStableId), Is.EqualTo(new[] { "id1" }), + "only the designated companion classes promote; other dynamic editors keep their unsupported row"); + Assert.That(AvaloniaCompanionSlices.SelectPromotions(null), Is.Empty); + } + + [Test] + public void SelectPromotions_OnTheRealComposedEntry_PromotesExactlyTheMessagesSlice() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var promotions = AvaloniaCompanionSlices.SelectPromotions(composed.CustomEditorFields); + + Assert.That(promotions.Select(p => p.ClassName), + Is.EqualTo(new[] { AvaloniaCompanionSlices.MessageSliceClassName })); + } + + [Test] + public void RemovePromotedFields_RemovesExactlyThePromotedRows() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var promotions = AvaloniaCompanionSlices.SelectPromotions(composed.CustomEditorFields); + var promotedIds = promotions.Select(p => p.FieldStableId).ToList(); + + var filtered = AvaloniaCompanionSlices.RemovePromotedFields(composed.Model, promotedIds); + + Assert.That(filtered.Fields.Count, Is.EqualTo(composed.Model.Fields.Count - promotedIds.Count), + "exactly the promoted rows disappear; everything else survives"); + Assert.That(filtered.Fields.Any(f => promotedIds.Contains(f.StableId)), Is.False, + "the grey unsupported row for the promoted slice is gone"); + Assert.That(filtered.Fields.Select(f => f.StableId), + Is.EqualTo(composed.Model.Fields.Where(f => !promotedIds.Contains(f.StableId)) + .Select(f => f.StableId)), + "row order is preserved"); + Assert.That(filtered.ClassName, Is.EqualTo(composed.Model.ClassName)); + Assert.That(filtered.LayoutName, Is.EqualTo(composed.Model.LayoutName)); + Assert.That(filtered.Diagnostics, Is.SameAs(composed.Model.Diagnostics), + "diagnostics ride the filtered model unchanged"); + } + + [Test] + public void RemovePromotedFields_WithNothingToRemove_ReturnsTheSameModelInstance() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + + Assert.That(AvaloniaCompanionSlices.RemovePromotedFields(composed.Model, null), + Is.SameAs(composed.Model)); + Assert.That(AvaloniaCompanionSlices.RemovePromotedFields(composed.Model, + new[] { "no-such-row" }), + Is.SameAs(composed.Model), "unknown ids must not rebuild the model"); + } + } +} diff --git a/Src/xWorks/xWorksTests/RecordClerkNavigationContextTests.cs b/Src/xWorks/xWorksTests/RecordClerkNavigationContextTests.cs new file mode 100644 index 0000000000..de6366fd0e --- /dev/null +++ b/Src/xWorks/xWorksTests/RecordClerkNavigationContextTests.cs @@ -0,0 +1,198 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Windows.Forms; +using System.Xml; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Infrastructure; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Task 3.12 — the bidirectional selection bridge. Proves on the real product host that + /// RecordClerkNavigationContext follows the clerk's actual mediator broadcast (no manual + /// handler calls) and publishes a surface-originated selection back through the same bus. + /// + [TestFixture] + [Apartment(System.Threading.ApartmentState.STA)] + public class RecordClerkNavigationContextTests : XWorksAppTestBase + { + private PropertyTable m_propertyTable; + private List m_createdObjects; + + protected override void Init() + { + m_application = new MockFwXApp(new MockFwManager { Cache = Cache }, null, null); + m_configFilePath = Path.Combine(FwDirectoryFinder.CodeDirectory, m_application.DefaultConfigurationPathname); + } + + [SetUp] + public void SetUpWindow() + { + m_window = new MockFwXWindow(m_application, m_configFilePath); + ((MockFwXWindow)m_window).Init(Cache); + m_propertyTable = m_window.PropTable; + m_propertyTable.RemoveLocalAndGlobalSettings(); + m_window.LoadUI(m_configFilePath); + m_createdObjects = new List(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, CreateLexiconTestData); + } + + [TearDown] + public void TearDownWindow() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, DestroyLexiconTestData); + m_createdObjects = null; + m_propertyTable?.RemoveLocalAndGlobalSettings(); + m_propertyTable = null; + if (m_window != null && !m_window.IsDisposed) + { + m_window.Dispose(); + m_window = null; + } + } + + [Test] + public void SelectionBridge_FollowsRealBroadcast_AndPublishesSelectionBack() + { + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + Assert.That(control.Clerk.ListSize, Is.GreaterThanOrEqualTo(2), "need at least two records to navigate"); + + var bridge = control.RecordNavigationContext; + Assert.That(bridge, Is.Not.Null, "the host must expose the selection bridge once its clerk exists"); + + var changed = 0; + bridge.CurrentRecordChanged += (s, e) => changed++; + + control.Clerk.JumpToIndex(0); + DrainMediatorAndIdleQueues(); + var first = bridge.CurrentRecord as ICmObject; + Assert.That(first, Is.Not.Null); + + // Follow direction: moving the bus selection must reach the bridge through the real + // mediator RecordNavigation broadcast handled by the sponsoring host. + var changedBeforeMove = changed; + Assert.That(bridge.MoveNext(), Is.True); + DrainMediatorAndIdleQueues(); + var second = bridge.CurrentRecord as ICmObject; + Assert.That(second, Is.Not.Null); + Assert.That(second.Hvo, Is.Not.EqualTo(first.Hvo), "MoveNext must change the bus selection"); + Assert.That(changed, Is.GreaterThan(changedBeforeMove), + "the bridge must observe the broadcast (follow direction)"); + + // Publish direction: a surface-originated selection (by record object) must route through + // the clerk's real OnJumpToRecord and broadcast back to the host. + var changedBeforePublish = changed; + Assert.That(bridge.PublishSelection(first), Is.True); + DrainMediatorAndIdleQueues(); + Assert.That(((ICmObject)bridge.CurrentRecord).Hvo, Is.EqualTo(first.Hvo), + "PublishSelection must move the bus selection (publish direction)"); + Assert.That(control.Clerk.CurrentObject.Hvo, Is.EqualTo(first.Hvo), + "the legacy clerk must see the surface-published selection"); + Assert.That(changed, Is.GreaterThan(changedBeforePublish), + "the publishing surface also follows its own published change via the bus"); + } + + [Test] + public void SelectionBridge_PublishSelection_ByHvo_AndRejectsUnknownKeys() + { + LoadRecordEditView("lexiconEdit"); + DrainMediatorAndIdleQueues(); + + var control = m_propertyTable.GetValue("currentContentControlObject", null) as RecordEditView; + Assert.That(control, Is.Not.Null); + EnsureCurrentRecord(control); + + var bridge = control.RecordNavigationContext; + control.Clerk.JumpToIndex(0); + DrainMediatorAndIdleQueues(); + var first = (ICmObject)bridge.CurrentRecord; + + Assert.That(bridge.MoveNext(), Is.True); + DrainMediatorAndIdleQueues(); + + Assert.That(bridge.PublishSelection(first.Hvo), Is.True, "an hvo key publishes"); + DrainMediatorAndIdleQueues(); + Assert.That(((ICmObject)bridge.CurrentRecord).Hvo, Is.EqualTo(first.Hvo)); + + Assert.That(bridge.PublishSelection("not-a-record-key"), Is.False, + "unknown key shapes are rejected, not guessed"); + } + + private void LoadRecordEditView(string toolValue) + { + var windowConfiguration = m_propertyTable.GetValue("WindowConfiguration"); + Assert.That(windowConfiguration, Is.Not.Null); + var controlNode = windowConfiguration.SelectSingleNode( + string.Format("//tool[@value='{0}']/control//control[dynamicloaderinfo/@class='SIL.FieldWorks.XWorks.RecordEditView']", toolValue)); + Assert.That(controlNode, Is.Not.Null, "Expected the RecordEditView configuration node for tool '{0}'.", toolValue); + + m_propertyTable.SetProperty("currentContentControlParameters", controlNode, true); + m_propertyTable.SetPropertyPersistence("currentContentControlParameters", false); + m_propertyTable.SetProperty("currentContentControl", toolValue, true); + m_propertyTable.SetPropertyPersistence("currentContentControl", false); + } + + private void CreateLexiconTestData() + { + var stemMorphType = GetMorphTypeOrCreateOne("stem"); + var nounPartOfSpeech = GetGrammaticalCategoryOrCreateOne("noun", Cache.LangProject.PartsOfSpeechOA); + AddLexeme(m_createdObjects, "alpha-entry", stemMorphType, "first gloss", nounPartOfSpeech); + AddLexeme(m_createdObjects, "beta-entry", stemMorphType, "second gloss", nounPartOfSpeech); + } + + private void DestroyLexiconTestData() + { + if (m_createdObjects == null) + return; + foreach (var obj in m_createdObjects) + { + if (!obj.IsValidObject) + continue; + if (obj is ILexEntry) + obj.Delete(); + } + } + + private void DrainMediatorAndIdleQueues() + { + var idleQueue = m_window.Mediator.IdleQueue; + var processIdle = idleQueue.GetType().GetMethod("Application_Idle", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(processIdle, Is.Not.Null); + + for (var iteration = 0; iteration < 8; iteration++) + { + ((MockFwXWindow)m_window).ProcessPendingItems(); + if (idleQueue.Count == 0 && m_window.Mediator.JobItems == 0) + break; + if (idleQueue.Count > 0) + processIdle.Invoke(idleQueue, new object[] { this, EventArgs.Empty }); + } + + Application.DoEvents(); + } + + private void EnsureCurrentRecord(RecordEditView control) + { + if (control.Clerk.CurrentObject != null) + return; + control.Clerk.JumpToIndex(0); + DrainMediatorAndIdleQueues(); + Assert.That(control.Clerk.CurrentObject, Is.Not.Null); + } + } +} diff --git a/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs index fa4d0c3b75..5aaf316614 100644 --- a/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs +++ b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs @@ -10,6 +10,7 @@ using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; using SIL.LCModel.Infrastructure; @@ -83,6 +84,11 @@ public void AvaloniaActive_DoesNotInitializeOrDriveLegacyDataTree() Assert.That(GetPrivateFieldValue(control, "m_legacySurfaceInitialized"), Is.EqualTo(false), "The active Avalonia path must not instantiate or drive the hidden legacy DataTree (task 3.10)."); + var panel = (Panel)GetPrivateFieldValue(control, "m_panel"); + var legacyDataTree = (DataTree)GetPrivateFieldValue(control, "m_dataEntryForm"); + Assert.That(panel.Controls.Contains(legacyDataTree), Is.False, + "The dormant legacy DataTree must not remain parented in the panel while Avalonia is the active surface."); + // Note: realizing the Avalonia WinForms-interop host requires a real UI context, which this // headless xWorks harness does not provide, so we do not assert the host was created here. The // FwAvaloniaTests headless suite covers Avalonia surface construction/rendering directly. diff --git a/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs b/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs new file mode 100644 index 0000000000..419e0606ea --- /dev/null +++ b/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs @@ -0,0 +1,313 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Threading; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Review round-1 hardening of the fenced edit session against the rest of the app: + /// (1) global Undo/Redo while a session is open used to throw LockRecursionException + /// (UndoStack.Undo re-enters the non-recursive UOW write lock the open task already holds) — + /// the holder's undo guard settles the session and converts the gesture into "close the + /// pending edit"; (2) Settle is the one auto-save policy (commit when valid, cancel when not) + /// shared by every host path (navigation, go-away, undo, dispose); (3) the refresh controller + /// coalesces PropChanged bursts through a host scheduler instead of recomposing the region + /// synchronously once per notification. + /// + [TestFixture] + public class RegionEditGuardAndSchedulingTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + }); + } + + private string LexemeText => m_entry.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text; + + private LexicalEditRegionEditContext OpenSessionWith(string text) + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + Assert.That(context.TrySetText(LexicalEditRegionEditingTests.F("Form"), "vern", text), Is.True); + Assert.That(context.IsOpen, Is.True); + return context; + } + + [Test] + public void Settle_CommitsAValidOpenSession_AsOneUndoStep() + { + var holder = new RegionEditContextHolder(); + holder.Replace(OpenSessionWith("perro")); + + holder.Settle(); + + Assert.That(holder.Current.IsOpen, Is.False); + Assert.That(LexemeText, Is.EqualTo("perro"), "a valid pending edit is saved, not discarded"); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(LexemeText, Is.EqualTo("casa"), "the settle is one normal undo step"); + } + + [Test] + public void Settle_CancelsAnInvalidOpenSession() + { + var holder = new RegionEditContextHolder(); + holder.Replace(OpenSessionWith("")); // empties the lexeme form -> validation error + + holder.Settle(); + + Assert.That(holder.Current.IsOpen, Is.False); + Assert.That(LexemeText, Is.EqualTo("casa"), "invalid staged state rolls back instead of committing"); + Assert.That(Cache.ActionHandlerAccessor.CurrentDepth, Is.EqualTo(0)); + } + + // Each undo test needs a PRIOR committed bundle (CanUndo requires one) and must never leak + // an open session into the shared fixture cache, hence the try/finally shape. + private void WithPriorUndoStepAndOpenSession(Action test) + { + UndoableUnitOfWorkHelper.Do("Undo seed", "Redo seed", Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("seed", Cache.DefaultVernWs))); + var context = OpenSessionWith("perro"); + try + { + test(context); + } + finally + { + if (context.IsOpen) + context.Cancel(); + } + } + + // Characterization of the bug the guard exists for: LCModel's UndoStack.Undo() enters the + // non-recursive UOW write lock, which the open fenced task's thread already holds. + [Test] + public void Undo_WhileASessionIsOpen_WithoutTheGuard_Throws() + { + WithPriorUndoStepAndOpenSession(context => + { + Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True, + "precondition: LCModel reports undo available even while a task is open"); + Assert.That(() => Cache.ActionHandlerAccessor.Undo(), + Throws.InstanceOf(), + "characterization: Edit > Undo mid-edit crashes without the guard"); + }); + } + + [Test] + public void UndoGuard_UndoWhileSessionOpen_SettlesThePendingEditInsteadOfThrowing() + { + WithPriorUndoStepAndOpenSession(context => + { + var holder = new RegionEditContextHolder(); + holder.AttachUndoGuard(Cache.ActionHandlerAccessor); + try + { + holder.Replace(context); + + Assert.That(() => Cache.ActionHandlerAccessor.Undo(), Throws.Nothing, + "the guard must intercept the undo before it re-enters the write lock"); + Assert.That(Cache.ActionHandlerAccessor.CurrentDepth, Is.EqualTo(0), "the session settled"); + Assert.That(LexemeText, Is.EqualTo("perro"), + "the first undo gesture closes the pending edit (it does not also undo it)"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(LexemeText, Is.EqualTo("casa"), "the next undo reverts the settled edit"); + } + finally + { + holder.DetachUndoGuard(); + } + }); + } + + [Test] + public void UndoGuard_Detached_StopsIntercepting() + { + WithPriorUndoStepAndOpenSession(context => + { + var holder = new RegionEditContextHolder(); + holder.AttachUndoGuard(Cache.ActionHandlerAccessor); + holder.DetachUndoGuard(); + holder.Replace(context); + + Assert.That(() => Cache.ActionHandlerAccessor.Undo(), + Throws.InstanceOf(), + "after detach the guard must not linger on the action handler"); + }); + } + + [Test] + public void RefreshController_WithAScheduler_CoalescesABurstIntoOneRefresh() + { + var scheduled = new List(); + var refreshes = 0; + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator(), + schedule: scheduled.Add)) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("one", Cache.DefaultVernWs)); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("two", Cache.DefaultAnalWs)); + m_entry.SensesOS[0].Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("three", Cache.DefaultAnalWs)); + }); + + Assert.That(scheduled.Count, Is.EqualTo(1), + "a burst of PropChanged notifications coalesces into one scheduled refresh"); + Assert.That(refreshes, Is.EqualTo(0), "nothing runs until the host's scheduler fires"); + + scheduled[0](); + Assert.That(refreshes, Is.EqualTo(1)); + + // The next change after the flush schedules a fresh refresh. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("four", Cache.DefaultVernWs))); + Assert.That(scheduled.Count, Is.EqualTo(2)); + } + } + + // Review round 2: a rebuild can itself raise PropChanged (a settle-commit inside it). Those + // changes are already covered — the recompose reads current domain state — so they must + // coalesce into the running delivery, not queue a second identical recompose. + [Test] + public void RefreshController_ChangeRaisedDuringTheRebuild_CoalescesIntoIt() + { + var scheduled = new List(); + var refreshes = 0; + AvaloniaRegionRefreshController controller = null; + void Refresh() + { + refreshes++; + if (refreshes == 1) + { + // Simulate a settle-commit inside the rebuild: a domain write raising PropChanged + // while _refresh() is still on the stack. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("inside", Cache.DefaultVernWs))); + } + } + using (controller = new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, Refresh, new RefreshCoordinator(), + schedule: scheduled.Add)) + { + controller.RequestRefresh(); + Assert.That(scheduled.Count, Is.EqualTo(1)); + + scheduled[0](); + Assert.That(refreshes, Is.EqualTo(1)); + Assert.That(scheduled.Count, Is.EqualTo(1), + "a change raised DURING the rebuild must not re-queue a second identical recompose"); + + // And the queue is open again afterwards. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("after", Cache.DefaultVernWs))); + Assert.That(scheduled.Count, Is.EqualTo(2)); + } + } + + [Test] + public void RefreshController_RefreshThrows_QueueDoesNotWedge() + { + var scheduled = new List(); + var refreshes = 0; + void Refresh() + { + refreshes++; + if (refreshes == 1) + throw new InvalidOperationException("rebuild failed"); + } + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, Refresh, new RefreshCoordinator(), + schedule: scheduled.Add)) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("boom", Cache.DefaultVernWs))); + Assert.That(scheduled.Count, Is.EqualTo(1)); + Assert.That(() => scheduled[0](), Throws.InstanceOf()); + + // The flag reset in finally: the next change schedules a fresh refresh. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("again", Cache.DefaultVernWs))); + Assert.That(scheduled.Count, Is.EqualTo(2)); + scheduled[1](); + Assert.That(refreshes, Is.EqualTo(2)); + } + } + + [Test] + public void RefreshController_SchedulerThrows_QueueDoesNotWedge() + { + var schedulerThrows = true; + var scheduled = new List(); + var refreshes = 0; + void Schedule(Action runner) + { + if (schedulerThrows) + throw new ObjectDisposedException("host scheduler gone"); + scheduled.Add(runner); + } + using (var controller = new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator(), + schedule: Schedule)) + { + Assert.That(() => controller.RequestRefresh(), Throws.InstanceOf()); + + schedulerThrows = false; + controller.RequestRefresh(); + Assert.That(scheduled.Count, Is.EqualTo(1), "a failed schedule must not wedge the queue"); + scheduled[0](); + Assert.That(refreshes, Is.EqualTo(1)); + } + } + + [Test] + public void RefreshController_DiscardHeldRefresh_DropsTheHeldDeliveryWithoutRefreshing() + { + var editing = true; + var refreshes = 0; + using (var controller = new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => editing, () => refreshes++, new RefreshCoordinator())) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("raced", Cache.DefaultVernWs))); + + editing = false; + controller.DiscardHeldRefresh(); + Assert.That(refreshes, Is.EqualTo(0), + "the host discards the held delivery when it is about to re-show anyway"); + + controller.NotifyEditCompleted(); + Assert.That(refreshes, Is.EqualTo(0), "nothing is left pending after the discard"); + } + } + } +} diff --git a/Src/xWorks/xWorksTests/RegionEditSessionLifecycleTests.cs b/Src/xWorks/xWorksTests/RegionEditSessionLifecycleTests.cs new file mode 100644 index 0000000000..06b84af265 --- /dev/null +++ b/Src/xWorks/xWorksTests/RegionEditSessionLifecycleTests.cs @@ -0,0 +1,149 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Lifecycle hardening for the fenced edit session (the "Commit at wrong place" shutdown crash): + /// an LCModel undo task left open anywhere makes every later IUndoStackManager.Save() — + /// including the one FieldWorks runs at shutdown — throw. These tests pin down the failure + /// mechanism and prove the two seams that prevent it: + /// (the host never orphans an open context when re-showing a region) and the defensive + /// Commit/Cancel (safe even after the clerk force-ended the + /// task through RecordClerk.SaveOnChangeRecord, the LT-16673 path). + /// + [TestFixture] + public class RegionEditSessionLifecycleTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + var sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(sense); + }); + } + + private LexicalEditRegionField FormField => LexicalEditRegionEditingTests.F("Form"); + + private void ShutdownStyleSave() + => Cache.ServiceLocator.GetInstance().Save(); + + // Characterizes the crash mechanism (the error report's stack): an undo task abandoned + // mid-edit makes the shutdown Save throw InvalidOperationException "Commit at wrong place." + [Test] + public void Save_WithAnAbandonedOpenSession_ThrowsCommitAtWrongPlace() + { + var abandoned = new LexicalEditRegionEditContext(m_entry, Cache); + abandoned.TrySetText(FormField, "vern", "half-typed"); + Assert.That(abandoned.IsOpen, Is.True); + + // Nobody commits or cancels; this is exactly the state FieldWorks was in at shutdown. + Assert.That(() => ShutdownStyleSave(), + Throws.InvalidOperationException.With.Message.Contains("Commit at wrong place"), + "characterization: an orphaned fenced session breaks every later Save"); + // (CheckReadyForCommit rolls the abandoned task back before throwing, so the fixture + // is clean for the next test.) + } + + [Test] + public void Holder_ReplacingAContextMidEdit_CancelsTheOpenSession_SoShutdownSaveSucceeds() + { + var holder = new RegionEditContextHolder(); + var first = new LexicalEditRegionEditContext(m_entry, Cache); + holder.Replace(first); + first.TrySetText(FormField, "vern", "half-typed"); + Assert.That(first.IsOpen, Is.True); + + // Re-showing the region (navigation, refresh, ShowHiddenFields, …) swaps the context. + var second = new LexicalEditRegionEditContext(m_entry, Cache); + holder.Replace(second); + + Assert.That(first.IsOpen, Is.False, "the displaced context's open session must be cancelled, never orphaned"); + Assert.That(Cache.ActionHandlerAccessor.CurrentDepth, Is.EqualTo(0), "no undo task may stay open"); + Assert.That(LexemeText, Is.EqualTo("casa"), "the half-typed edit rolls back"); + Assert.That(() => ShutdownStyleSave(), Throws.Nothing, "shutdown Save must succeed after a mid-edit re-show"); + } + + [Test] + public void Holder_ClearMidEdit_CancelsTheOpenSession() + { + var holder = new RegionEditContextHolder(); + var context = new LexicalEditRegionEditContext(m_entry, Cache); + holder.Replace(context); + context.TrySetText(FormField, "vern", "half-typed"); + + holder.Clear(); // dispose / non-entry record path + + Assert.That(context.IsOpen, Is.False); + Assert.That(holder.Current, Is.Null); + Assert.That(() => ShutdownStyleSave(), Throws.Nothing); + } + + [Test] + public void Holder_ReplacingWithTheSameContext_DoesNotCancelIt() + { + var holder = new RegionEditContextHolder(); + var context = new LexicalEditRegionEditContext(m_entry, Cache); + holder.Replace(context); + context.TrySetText(FormField, "vern", "still typing"); + + holder.Replace(context); + + Assert.That(context.IsOpen, Is.True, "re-assigning the same context must not kill the user's open edit"); + context.Cancel(); + } + + // RecordClerk.SaveOnChangeRecord (LT-16673) force-ends any open undo task when the record + // changes. The session must notice its task is gone instead of throwing + // ("Rollback not supported in the current state" / unbalanced EndUndoTask). + [Test] + public void Cancel_AfterTheClerkForceEndedTheTask_IsASafeNoOp() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + context.TrySetText(FormField, "vern", "perro"); + Cache.ActionHandlerAccessor.EndUndoTask(); // what RecordClerk.SaveOnChangeRecord does + + Assert.That(() => context.Cancel(), Throws.Nothing, + "cancelling a session whose task was force-ended elsewhere must not throw"); + Assert.That(Cache.ActionHandlerAccessor.CurrentDepth, Is.EqualTo(0)); + Assert.That(() => ShutdownStyleSave(), Throws.Nothing); + + // The force-ended task became a normal undo step; undo it to restore the fixture. + Cache.ActionHandlerAccessor.Undo(); + Assert.That(LexemeText, Is.EqualTo("casa")); + } + + [Test] + public void Commit_AfterTheClerkForceEndedTheTask_IsASafeNoOp() + { + var context = new LexicalEditRegionEditContext(m_entry, Cache); + context.TrySetText(FormField, "vern", "perro"); + Cache.ActionHandlerAccessor.EndUndoTask(); // what RecordClerk.SaveOnChangeRecord does + + Assert.That(() => context.Commit(), Throws.Nothing, + "committing a session whose task was force-ended elsewhere must not throw"); + Assert.That(Cache.ActionHandlerAccessor.CurrentDepth, Is.EqualTo(0)); + Assert.That(() => ShutdownStyleSave(), Throws.Nothing); + + Cache.ActionHandlerAccessor.Undo(); + } + + private string LexemeText => m_entry.LexemeFormOA.Form.get_String(Cache.DefaultVernWs).Text; + } +} diff --git a/openspec/changes/avalonia-migration-roadmap/design.md b/openspec/changes/avalonia-migration-roadmap/design.md index 27d7b2b2c9..a146bd418d 100644 --- a/openspec/changes/avalonia-migration-roadmap/design.md +++ b/openspec/changes/avalonia-migration-roadmap/design.md @@ -96,7 +96,7 @@ flowchart TB G0{"Gate 0
host bridge proven +
density acceptable +
flag dual-run works"}:::gate G1{"Gate 1
LexicalEditRegionView at semantic parity,
DataTree untouched on legacy path,
active-host contract proven, no native/Graphite"}:::gate - G2{"Gate 2
Lexical Edit region complete:
parity, native audit clean,
Graphite-free default"}:::gate + G2{"Gate 2
Lexical Edit region complete:
parity, native audit clean,
Graphite-warned default
(no native Graphite engine)"}:::gate P0 --> G0 --> P1 --> G1 --> P2 --> G2 --> P7 @@ -121,7 +121,10 @@ flowchart TB complete as of 2026-06-09.)** - **Gate 2 (program → shell):** the Lexical Edit region manifest passes — semantic parity, UIA2 legacy baselines, Avalonia.Headless tests, render-comparison evidence, native-viewing audit clean, and no - unapproved Graphite/native-rendering default-path dependency. + native Graphite engine or native Views shaping on the Avalonia path. (Per + `graphite-transition-support`, 2026-06-09: Graphite *presence* in a project no longer blocks an + Avalonia default — the gate is per-writing-system classification + warning coverage; Graphite stays + fully supported on legacy surfaces until the M2 sunset milestone.) ## Vocabulary — as-built (Phase 1) diff --git a/openspec/changes/graphite-transition-support/.openspec.yaml b/openspec/changes/graphite-transition-support/.openspec.yaml new file mode 100644 index 0000000000..573544603b --- /dev/null +++ b/openspec/changes/graphite-transition-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-09 diff --git a/openspec/changes/graphite-transition-support/design.md b/openspec/changes/graphite-transition-support/design.md new file mode 100644 index 0000000000..cbc60caaee --- /dev/null +++ b/openspec/changes/graphite-transition-support/design.md @@ -0,0 +1,186 @@ +# Graphite Transition Support — Design + +## Context + +Graphite is SIL's smart-font shaping technology for scripts OpenType handles poorly or not at all. +In this repo it lives in the native Views render path (`GraphiteEngine.cpp`, `GraphiteSegment`, +`RenderEngineFactory` selection), in writing-system storage (`IsGraphiteEnabled`, +`DefaultFontFeatures`, `FontEngines.Graphite`), in writing-system setup UI, in Gecko preview/PDF +(`gfx.font_rendering.graphite.enabled`), and in shipped/test fonts. + +Two font realities drive this design: + +1. **Most current SIL fonts are dual-engine.** Charis SIL, Doulos SIL, Scheherazade New, Annapurna + and others carry both OpenType (`GSUB`/`GPOS`) and Graphite (`Silf`/`Glat`/`Gloc`/`Feat`) tables. + For these, OpenType shaping is usually correct; only projects using Graphite-specific *feature + settings* see differences. +2. **Some fonts are Graphite-only.** Awami Nastaliq (Nastaliq-style Arabic) is the prominent + shipped example; older custom minority-language fonts exist in the field. For these, OpenType + shaping produces visibly wrong text. + +The Avalonia text stack (Skia + HarfBuzzSharp) ships HarfBuzz **without** Graphite2 enabled +(HarfBuzz's own docs: `-Dgraphite=enabled` is off by default), so Avalonia cannot shape Graphite +fonts today, and pretending otherwise was never an option. The question is what users experience +during the ~1-year coexistence, and when Graphite support actually ends. + +Timeline anchors used below (WinForms decommissioning milestones): + +- **M0** — coexistence begins (UIMode switch shipped; where we are now). +- **M1** — first editable Avalonia slice is product-ready (lexical-edit 6.x complete). +- **M2** — *mid-decommissioning*: Avalonia is the default for Lexical Edit and the majority of + `RecordEditView` consumers. **This is the Graphite sunset point.** +- **M3** — WinForms (and native Views, including `GraphiteEngine`) deleted. + +## Goals / Non-Goals + +**Goals:** +- Graphite-using projects keep a fully working editing path (legacy surfaces) until M2, and a + clearly communicated, tooling-supported migration path from M2 to M3. +- Avalonia surfaces never silently change rendering: Graphite-enabled writing systems on Avalonia + get OpenType shaping plus an actionable warning, graded by how much actually breaks. +- Graphite policy stops gating UI-region completion; the gate becomes "warning + classification + coverage", which is testable per region. +- Keep the native-engine boundary intact: no native Graphite/Views shaping inside Avalonia. + +**Non-Goals:** +- No Graphite shaping in Avalonia (recorded contingency only — Path B). +- No legacy editor islands inside Avalonia surfaces (rejected — Path C). +- No changes to Gecko/PDF/export Graphite behavior; that classification stays in the lexical-edit + change. + +## The three paths considered + +### Path A — Legacy harbor + graded Avalonia warning (RECOMMENDED) + +Graphite stays exactly as it is on legacy surfaces; the global UI mode is the support boundary. +When an Avalonia surface is about to render a writing system classified as Graphite-affected, it +shapes with OpenType and raises a graded, actionable warning (see classification below). The +warning offers: switch this project to Legacy UI mode (full Graphite, one click), keep going with +OpenType shaping, or open font-migration guidance. Sunset at M2: warnings escalate to deprecation +notices, migration tooling ships, new projects can no longer enable Graphite; removal at M3 with +WinForms. + +- **Pros:** zero native work; no new packaging risk; honest UX with a real escape hatch (legacy + mode is a supported product surface, not a fallback apology); preserves every existing audit + gate (no native Graphite on the Avalonia path); decouples font migration from UI migration; + testable headlessly (classification + warning are managed code). +- **Cons:** Graphite-only-font projects get degraded rendering whenever they choose the Avalonia + surface before migrating fonts; per-WS warnings must be carefully rate-limited to avoid nagging; + dual-engine fonts with Graphite feature strings may render subtly differently and users may not + notice the warning's significance. +- **Cost/risk:** low/low. + +### Path B — Graphite shaping in Avalonia (HarfBuzz + Graphite2) + +Ship a HarfBuzzSharp/Skia native build with Graphite2 enabled (or sideload `graphite2` + +`hb-graphite2` and a custom Avalonia `ITextShaperImpl`), so Avalonia shapes Graphite fonts +natively. The warning becomes rare (only Graphite *feature-UI* gaps). + +- **Pros:** best user continuity — Graphite projects see correct text on both surfaces; no forced + font migration at M2 (sunset could slip to M3 or beyond); Graphite2 is a shaping library, not the + Views render pipeline, so it does not violate the native-Views decommissioning gate per se. +- **Cons:** custom native builds of HarfBuzzSharp/Skia must be produced, packaged, and re-produced + for every Avalonia/HarfBuzzSharp update while pinned to 11.x — a year of fork maintenance; + Avalonia's text shaping internals are not a stable extension point on 11.x; adds exactly the + kind of bespoke native dependency the migration is trying to shed; testing burden (shaping + parity fixtures against native `GraphiteEngine` output). +- **Cost/risk:** high/medium-high. +- **Status:** recorded contingency. Pivot triggers (any one): the M1 fixture scan finds Graphite-only + fonts in active use beyond an acceptable threshold (proposed: >10% of partner projects or any + strategic-language project with no OpenType replacement font); SIL Language Technology commits to + maintaining an hb-graphite2 build for another product (shared cost); or Avalonia 12 adoption + (post-WinForms) makes a custom shaper sustainable. + +### Path C — Per-field legacy bridge (Graphite islands in Avalonia) + +When a field's writing system is Graphite-enabled, the Avalonia surface hosts the legacy +native-Views/RootSite editor for that field only; other fields use Avalonia editors. + +- **Pros:** pixel-true Graphite editing inside the new surface; no warning needed. +- **Cons (disqualifying):** violates the active-host contract (3.10) that the migration just made + an audited invariant; keeps native Views render/editor code alive *inside* "migrated" regions, + which the region manifests forbid; fights the coarse-hosting constraint (Avalonia 11.x + cross-boundary focus/tab bugs are documented and unfixable on the pinned line); per-field interop + islands multiply the dialog-ownership, focus, and DPI problems the coexistence constraints + explicitly avoid; and it builds throwaway plumbing at the worst possible granularity. +- **Cost/risk:** high/high. +- **Status:** rejected. If full-fidelity Graphite editing is ever required inside an Avalonia-mode + app, the correct unit is the *whole surface* (the user switches the host to legacy mode — which + is Path A's affordance), not a field island. + +**Decision: Path A**, with Path B held as a contingency behind the recorded pivot triggers, and +Path C rejected. + +## Writing-system / font classification (drives warning severity) + +Classification runs per writing system at surface-resolution time, from immutable inputs +(`IsGraphiteEnabled`, `DefaultFontFeatures`, and font-table sniffing of the resolved font file): + +| Tier | Condition | Avalonia behavior | +|---|---|---| +| **G0 — unaffected** | `IsGraphiteEnabled` false, or font has no Graphite tables | Normal OpenType rendering; no message. | +| **G1 — dual-engine, no feature strings** | Graphite-enabled + font has both `GSUB`/`GPOS` and `Silf` tables + no Graphite feature settings | OpenType rendering; **Info** diagnostic only (logged, visible in WS setup, not a popup). | +| **G2 — dual-engine with Graphite feature strings** | As G1 but `DefaultFontFeatures` (or per-WS feature overrides) carry Graphite feature IDs | OpenType rendering; **Warning**: "Graphite font features for ⟨WS⟩ do not apply in the new editor; rendering may differ." Once per project session, actionable. | +| **G3 — Graphite-only font** | Font has `Silf` but no functional OpenType shaping for the script (e.g. Awami Nastaliq) | OpenType/fallback rendering will be wrong; **prominent Warning** before first render: recommend Legacy UI mode or a replacement font. Never suppressed permanently while the condition holds. | + +Best-practice notes baked into the tiers: + +- **Sniff tables, don't trust flags alone.** `IsGraphiteEnabled` is a user preference; the `Silf` + + `GSUB`/`GPOS` table check is what predicts actual rendering damage. Both inputs feed the tier. +- **Never block, never silently degrade.** G2/G3 still render (the user may be triaging data, not + typography); the warning carries the "switch to Legacy UI" affordance that restores full + fidelity in one action. +- **Warnings are per writing system, not per field**, rate-limited to once per project session, + and recorded as diagnostics so support staff can see them after the fact. +- **The warning text names the font and the writing system**, links the font-migration guidance + (e.g. Awami Nastaliq → no current replacement: stay on legacy; Padauk/Charis feature users → + OpenType equivalents table), and is localized like any product string (lexical-edit 6.11 rules). + +## Sunset schedule and best practices + +| Milestone | Graphite state | Required before entering | +|---|---|---| +| M0 → M1 | Fully supported on legacy surfaces; G1–G3 warnings active on any Avalonia surface | Classification service + warning UX shipped with the first Avalonia surface that can render user text | +| M1 → M2 | Same, plus WS-setup UI shows per-WS Graphite status and migration guidance | LDML/project fixture scan quantifying G2/G3 prevalence (the Path B pivot input); font-replacement policy published | +| **M2 (sunset)** | Deprecated: new projects cannot enable Graphite; existing projects see deprecation notice with timeline; migration tooling (feature-string → OpenType mapping where possible, font-replacement assistant) ships | Tooling tested on the fixture corpus; at least one release of advance notice; partner sign-off for strategic languages | +| M2 → M3 | Legacy surfaces still render Graphite for existing projects (no functional removal), but it is support-frozen | — | +| **M3** | Removed with WinForms/native Views (`GraphiteEngine.*`, render-engine selection, Gecko prefs) | Every G3 project contacted/migrated or explicitly accepted as frozen-version users | + +Transition best practices (applies to whichever path): + +1. **Measure before sunsetting.** The M1 fixture scan is the evidence that M2 enforcement is + humane. If the scan surfaces heavy G3 usage, the pivot triggers fire *before* anyone is harmed. +2. **One support boundary, not many.** Fidelity is per-surface (legacy vs Avalonia), selected by + the existing UI mode — never per-field or per-control. This keeps the mental model and the test + matrix small. +3. **Keep storage authoritative and untouched.** `IsGraphiteEnabled`/`DefaultFontFeatures` are + user data; classification reads them. Migration tooling rewrites them only on explicit user + action, with undo. +4. **Tie warnings to diagnostics, not just UI.** Every G1–G3 determination is a structured + diagnostic (same channel as the view-definition diagnostics), so parity bundles and support + can audit what a user was told. +5. **Don't couple the audit gates to the policy.** The region-manifest forbidden-symbol audit + (`GraphiteEngineClass` etc. never on the Avalonia path) stays exactly as is under every path — + it is about *engine* isolation, which is true even while Graphite is fully supported. + +## Risks / Trade-offs + +- G3 users who never read warnings discover wrong rendering late → mitigate with the prominent + pre-render warning, WS-setup status surface, and the M1 scan proactively identifying affected + projects for direct contact. +- Warning fatigue for G1/G2 → mitigate with the tier system (G1 is log-only) and per-session + rate limiting. +- Path B pivot fires late, after users migrated fonts unnecessarily → mitigate by running the + fixture scan at M1 (early), not at M2. +- Sunset slips because tooling isn't ready → M2 enforcement is gated on the tooling, not the + calendar; the deprecation *notice* can ship without enforcement. + +## Open Questions + +1. What is the acceptable G3 prevalence threshold for the Path B pivot (proposed >10% of partner + projects — needs product owner confirmation)? +2. Does the M2 "new projects cannot enable Graphite" rule need a partner exemption mechanism? +3. Where does the font classification service live — `FwAvalonia` (LCModel-free, table sniffing + only) with the WS-flag input injected, or alongside the 6.x writing-system text service? +4. Should the deprecation notice be in-app only, or also release notes + partner communication + channel? diff --git a/openspec/changes/graphite-transition-support/proposal.md b/openspec/changes/graphite-transition-support/proposal.md new file mode 100644 index 0000000000..da055ecaaf --- /dev/null +++ b/openspec/changes/graphite-transition-support/proposal.md @@ -0,0 +1,83 @@ +# Graphite Transition Support + +## Why + +The lexical-edit migration plan currently treats Graphite as a hard blocker: "Avalonia SHALL never +support Graphite", decommissioning "starts with the migration", and Avalonia cannot become the +default Lexical Edit screen until Graphite is retired from the default path. That posture is wrong +for FieldWorks' users: Graphite-rendered writing systems are concentrated in exactly the minority- +language projects FieldWorks exists to serve, several SIL fonts are Graphite-only (e.g. Awami +Nastaliq), and forcing font migration as a precondition of the UI migration couples two risks that +should be sequenced separately. + +This change replaces the hard block with a supported transition: **Graphite remains fully supported +on legacy WinForms/native-Views surfaces through the first half of the WinForms decommissioning**, +Avalonia surfaces render Graphite-enabled writing systems with OpenType shaping plus an **actionable +warning** (never a silent rendering change and never a hard block), and Graphite support sunsets at +a defined mid-decommissioning milestone with migration tooling — not at migration start. + +It also extracts Graphite ownership out of `lexical-edit-avalonia-migration` (section 5 of its +tasks, `graphite-decommissioning.md`, and the blocking half of the `lexical-edit-font-decommissioning` +spec delta) into this dedicated change, so font policy stops gating UI-region completion. + +## What Changes + +- Establish the Graphite support policy for the coexistence period: supported on legacy surfaces, + warned-and-OpenType-shaped on Avalonia surfaces, sunset at the mid-decommissioning milestone (M2), + removed with WinForms (M3). +- Replace the "Graphite retirement blocks Avalonia default" gate with a "warning + diagnostics + coverage" gate: Avalonia may become a default surface while Graphite projects exist, provided the + per-writing-system warning, classification, and legacy-mode affordance are in place. +- Define the three-tier writing-system/font classification (dual-engine font, dual-engine with + Graphite feature strings, Graphite-only font) that drives warning severity and migration tooling. +- Define the warning UX contract: per writing system, actionable (switch-to-legacy affordance plus + font-replacement guidance), shown at most once per project session, never silently suppressed for + Graphite-only fonts. +- Keep the native-engine boundary unchanged: no path loads `GraphiteEngineClass`/native Views + Graphite shaping inside an Avalonia surface; the region-manifest forbidden-symbol audit is + unaffected. +- Define the sunset milestones, the user-facing deprecation sequence, and the font-migration + tooling that must ship before M2 enforcement. +- Supersede the blocking requirements in `lexical-edit-avalonia-migration`'s + `lexical-edit-font-decommissioning` spec delta and re-home its section-5 tasks here. + +## Non-goals + +- Implementing Graphite shaping inside Avalonia (Path B in `design.md` is a recorded contingency + with explicit pivot triggers, not planned work). +- Hosting legacy native-Views editors inside Avalonia surfaces for Graphite fields (Path C is + rejected; it violates the active-host contract). +- Changing Gecko/browser/PDF Graphite behavior — export/preview paths keep their own classification + under the lexical-edit change (tasks 5.6/5.7 there) and are unaffected by this policy until then. +- Removing any Graphite code, settings storage (`IsGraphiteEnabled`, `DefaultFontFeatures`), or + shipped fonts before M2. + +## Capabilities + +### New Capabilities + +- `graphite-transition-support`: Graphite support policy during WinForms decommissioning — + legacy-surface support, Avalonia warning behavior, writing-system/font classification, sunset + milestones, and migration tooling requirements. + +### Modified Capabilities + +- `lexical-edit-font-decommissioning` (delta carried by `lexical-edit-avalonia-migration`): the + "decommissioning starts with the migration" and "default screen is blocked by Graphite + dependency" requirements are superseded by this change's warning-based requirements. The + inventory, OpenType feature migration, Gecko/PDF classification, and native-dependency + classification requirements remain in the lexical-edit change. + +## Impact + +- Managed code: `Src/Common/FwAvalonia/` (warning surface + WS capability diagnostics), + `Src/xWorks/` (per-host warning wiring, legacy-mode affordance), `Src/FwCoreDlgs/` + (writing-system setup messaging), font classification service (new, location TBD with 6.x text + foundation). +- Native code: none removed or added. `Src/views/lib/GraphiteEngine.*` remains in service for + legacy surfaces until M3. +- Settings/data: `IsGraphiteEnabled` and `DefaultFontFeatures` remain authoritative storage; + classification reads them, never rewrites them without explicit user action. +- Docs: supersedes `lexical-edit-avalonia-migration/graphite-decommissioning.md` (banner added); + re-scopes lexical-edit tasks section 5; adjusts roadmap Gate 2 wording from "Graphite-free + default" to "Graphite-warned default with no native Graphite engine on the Avalonia path". diff --git a/openspec/changes/graphite-transition-support/specs/graphite-transition-support/spec.md b/openspec/changes/graphite-transition-support/specs/graphite-transition-support/spec.md new file mode 100644 index 0000000000..7cb6b9721d --- /dev/null +++ b/openspec/changes/graphite-transition-support/specs/graphite-transition-support/spec.md @@ -0,0 +1,93 @@ +## ADDED Requirements + +### Requirement: Graphite remains supported on legacy surfaces until the sunset milestone + +Graphite rendering SHALL remain fully functional on legacy WinForms/native-Views surfaces until the +M2 sunset milestone (Avalonia default for Lexical Edit and the majority of `RecordEditView` +consumers), and SHALL remain functionally available for existing projects from M2 until M3 +(WinForms removal). No Graphite code, writing-system setting, or shipped font SHALL be removed +before M2. + +#### Scenario: Legacy surface renders Graphite throughout coexistence +- **WHEN** a project with a Graphite-enabled writing system uses a legacy surface at any point before M2 +- **THEN** Graphite shaping SHALL behave exactly as it did before the Avalonia migration began + +#### Scenario: Sunset does not strand existing projects +- **WHEN** M2 enforcement begins +- **THEN** existing projects with Graphite-enabled writing systems SHALL continue to render via legacy surfaces until M3 +- **AND** migration tooling and a published font-replacement policy SHALL be available + +### Requirement: Avalonia surfaces warn instead of blocking when Graphite is requested + +When an Avalonia surface renders a writing system classified as Graphite-affected, it SHALL render +with OpenType/HarfBuzz shaping and SHALL raise a graded, actionable warning. The combination of +Graphite and Avalonia SHALL NOT hard-block the surface, and SHALL NOT silently change rendering +without the warning. Graphite retirement SHALL NOT gate a region's Avalonia-default decision; the +gate is warning and classification coverage. + +#### Scenario: Graphite-only font on an Avalonia surface +- **WHEN** an Avalonia surface is about to render a writing system whose resolved font is classified G3 (Graphite-only) +- **THEN** a prominent warning SHALL be shown before first render naming the writing system and font +- **AND** the warning SHALL offer switching the project to Legacy UI mode and font-migration guidance +- **AND** the text SHALL still render with fallback shaping if the user proceeds + +#### Scenario: Dual-engine font with Graphite feature settings +- **WHEN** an Avalonia surface renders a writing system classified G2 (dual-engine font with Graphite feature strings) +- **THEN** a warning SHALL state that Graphite font features do not apply on this surface, at most once per project session +- **AND** the determination SHALL be recorded as a structured diagnostic + +#### Scenario: Avalonia default is not blocked by Graphite presence +- **WHEN** a region proposes Avalonia as its default surface while Graphite-enabled writing systems exist in the project +- **THEN** the region's default decision SHALL be permitted provided classification and warning coverage are in place +- **AND** the native-engine audit (no `GraphiteEngineClass`/native Views shaping on the Avalonia path) SHALL still pass + +### Requirement: Writing systems are classified by actual rendering impact + +A classification service SHALL grade each writing system G0–G3 from `IsGraphiteEnabled`, +`DefaultFontFeatures`/per-WS feature settings, and font-table evidence (`Silf` and `GSUB`/`GPOS` +presence in the resolved font), at surface-resolution time, from immutable inputs. + +#### Scenario: Flag without Graphite tables is unaffected +- **WHEN** a writing system has `IsGraphiteEnabled` true but its resolved font carries no Graphite tables +- **THEN** it SHALL be classified G0 and produce no user-facing message + +#### Scenario: Dual-engine font without feature settings is informational only +- **WHEN** a writing system resolves to a font with both OpenType and Graphite tables and no Graphite feature settings +- **THEN** it SHALL be classified G1 and produce a log/setup-visible diagnostic, not a popup + +#### Scenario: Classification is deterministic and auditable +- **WHEN** the same project state is classified twice +- **THEN** the tiers SHALL be identical +- **AND** each determination SHALL be available as a structured diagnostic for parity bundles and support + +### Requirement: Sunset is milestone-gated and tooled, not calendar-gated + +M2 Graphite deprecation enforcement SHALL be gated on shipped migration tooling (Graphite +feature-string mapping where OpenType equivalents exist, font-replacement assistance), a completed +project/fixture scan quantifying G2/G3 prevalence, and at least one release of advance notice. +After M2, new projects SHALL NOT enable Graphite; existing projects SHALL see a deprecation notice +with the timeline. + +#### Scenario: Tooling gates enforcement +- **WHEN** M2 is reached but migration tooling has not shipped or the fixture scan is incomplete +- **THEN** deprecation NOTICE may ship but enforcement (blocking new Graphite enablement) SHALL wait for the tooling + +#### Scenario: Settings are user data +- **WHEN** migration tooling proposes converting a writing system's Graphite settings +- **THEN** `IsGraphiteEnabled`/`DefaultFontFeatures` SHALL be rewritten only on explicit user action with undo + +### Requirement: The Path B contingency has recorded pivot triggers + +Implementing Graphite shaping inside Avalonia (HarfBuzz with Graphite2) SHALL NOT be undertaken +unless a recorded pivot trigger fires: the fixture scan shows Graphite-only-font usage above the +agreed threshold, a shared SIL-maintained hb-graphite2 build becomes available, or post-WinForms +Avalonia adoption makes a custom shaper sustainable. Per-field legacy editor islands inside +Avalonia surfaces (Path C) SHALL NOT be built. + +#### Scenario: Pivot requires evidence +- **WHEN** Graphite-in-Avalonia work is proposed +- **THEN** the proposal SHALL cite which pivot trigger fired and its evidence + +#### Scenario: No legacy islands +- **WHEN** a Graphite field would render inside an Avalonia surface +- **THEN** the resolution SHALL be the warning plus whole-surface legacy mode, never a hosted native-Views field editor diff --git a/openspec/changes/graphite-transition-support/tasks.md b/openspec/changes/graphite-transition-support/tasks.md new file mode 100644 index 0000000000..67403abc01 --- /dev/null +++ b/openspec/changes/graphite-transition-support/tasks.md @@ -0,0 +1,35 @@ +# Tasks + +## 1. Policy and Inventory (M0 → M1) + +- [ ] 1.1 Adopt the Path A decision and record the Path B pivot-trigger threshold with the product owner (design.md Open Question 1); confirm the M2 definition ("Avalonia default for Lexical Edit + majority of `RecordEditView` consumers") as the sunset milestone. +- [ ] 1.2 Re-home the Graphite inventory tasks from `lexical-edit-avalonia-migration` section 5 (5.1–5.4) under this change: native code/assets, writing-system settings persistence, feature UI/storage classification, and the font-replacement/fallback policy. The Gecko/browser/PDF tasks (5.6/5.7) and the default-path native-engine audits (5.5/5.8, re-read as engine-isolation, not support-removal) stay with the lexical-edit change. +- [ ] 1.3 Build the writing-system/font classification service (tiers G0–G3): inputs `IsGraphiteEnabled`, `DefaultFontFeatures`/per-WS feature overrides, and font-table sniffing (`Silf`/`Glat` vs `GSUB`/`GPOS`) of the resolved font file; deterministic, immutable-input, structured-diagnostic output (same diagnostics channel as the view-definition pipeline). Decide its home (design.md Open Question 3) so the LCModel-free table sniffer can be tested in `FwAvaloniaTests`. +- [ ] 1.4 Validate the classifier against known fonts: Charis SIL/Doulos SIL/Scheherazade New (G1), a dual-engine font with Graphite feature settings fixture (G2), Awami Nastaliq (G3), and a Graphite-flag-without-Graphite-font fixture (G0). + +## 2. Warning UX on Avalonia Surfaces (lands with the first user-text-rendering Avalonia surface) + +- [ ] 2.1 Implement the graded warning: G1 log/setup-visible only; G2 once-per-project-session actionable warning; G3 prominent pre-render warning naming WS + font. All warnings carry the switch-to-Legacy-UI affordance (whole surface via the existing UI mode, never per-field) and a font-migration-guidance link; all texts localized per lexical-edit 6.11 rules with stable nonlocalized AutomationIds. +- [ ] 2.2 Record every G1–G3 determination as a structured diagnostic visible to support and included in Path 3 parity bundles. +- [ ] 2.3 Add headless tests: tier → warning behavior mapping, per-session rate limiting, G3 never permanently suppressed while the condition holds, and the legacy-mode affordance wiring through `LexicalEditSurfaceSelectionService`. +- [ ] 2.4 Replace the "Avalonia default blocked by Graphite" gate in region manifests with the "classification + warning coverage" gate; keep the forbidden-symbol audit (`GraphiteEngineClass`, `FwGrEngine`, `GraphiteSegment` never on the Avalonia path) unchanged. + +## 3. Visibility and Measurement (M1 → M2 entry criteria) + +- [ ] 3.1 Surface per-WS Graphite status (tier, font, what differs on the new editor) in the writing-system setup UI with migration guidance. +- [ ] 3.2 Run the LDML/project fixture scan quantifying G2/G3 prevalence across partner/test corpora; publish the result as the Path B pivot input and the M2 humane-enforcement evidence. +- [ ] 3.3 Publish the font-replacement policy: OpenType equivalents table for common Graphite feature uses; explicit "no replacement — stay on legacy until M3" entries (e.g. Awami Nastaliq) with owner contact. + +## 4. Sunset (M2) and Removal (M3) + +- [ ] 4.1 Ship migration tooling before M2 enforcement: Graphite feature-string → OpenType feature mapping where equivalents exist, font-replacement assistant; rewrites `IsGraphiteEnabled`/`DefaultFontFeatures` only on explicit user action, with undo; tested on the 3.2 fixture corpus. +- [ ] 4.2 M2 deprecation sequence: in-app deprecation notice with timeline for existing Graphite projects; block new Graphite enablement (with the exemption decision from design.md Open Question 2); at least one release of advance notice before enforcement. +- [ ] 4.3 Direct outreach to G3-classified strategic-language projects identified by the 3.2 scan before enforcement. +- [ ] 4.4 M3 removal (with WinForms/native Views deletion): `Src/views/lib/GraphiteEngine.*`, `GraphiteSegment`, render-engine Graphite selection, Gecko Graphite preference, Graphite-specific tests/sample assets/build artifacts; settings storage handled per the lexical-edit data-migration rules (values preserved, semantics retired). + +## 5. Validation + +- [ ] 5.1 Verify no Avalonia-path reference to native Graphite symbols at every milestone (existing region-manifest symbol audit — unchanged by this policy). +- [ ] 5.2 Verify warning coverage: every G2/G3 writing system in the fixture corpus produces its warning on an Avalonia surface, exactly once per session, with working legacy-mode affordance. +- [ ] 5.3 Verify legacy surfaces' Graphite rendering is bit-identical to pre-migration baselines (existing render-verification harness) at M1 and M2. +- [ ] 5.4 Verify M2 enforcement is gated on shipped tooling + completed scan + advance notice, not on the calendar. diff --git a/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md index 0d42dfa2ad..982ee10d0e 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md +++ b/openspec/changes/lexical-edit-avalonia-migration/architecture-diagrams.md @@ -410,7 +410,10 @@ flowchart TB The clean seam is the **surface-selection boundary** plus the **typed IR as the data contract** — not legacy re-plumbed through every port. Legacy stays frozen behind the switch until cutover. During the ~1-year coexistence, concurrent WinForms and Avalonia UI classes cooperate through a **shared selection -bus** and a **shared clipboard**, both bidirectional. +bus**, a **shared clipboard**, and **cross-surface drag-and-drop** (all bidirectional; DnD reuses the +clipboard payload formats — product decision 2026-06-09). Cross-surface **refresh propagation**, one +**global undo/redo stack**, and **dialog ownership/modality** are coexistence gates on the first +editable slice (tasks 3.15, 6.8/6.10, 3.16). ```mermaid flowchart TB @@ -426,13 +429,19 @@ flowchart TB AV --- Audit subgraph Substrate["Shared substrate (cooperation, bidirectional)"] - Sel["Selection bus
xCore RecordClerk / PropertyTable
'current lexeme'"]:::port - Clip["Clipboard
OS clipboard + FieldWorks WS text format"]:::port + Sel["Selection bus ✅ (3.12)
xCore RecordClerk / PropertyTable
'current lexeme'"]:::port + Clip["Clipboard (3.13)
OS clipboard + legacy 'TsString' format"]:::port + Dnd["Drag & drop (3.14)
OS DnD, same payloads as clipboard"]:::port + Refresh["Refresh propagation (3.15)
PropChanged / F5 reach both surfaces"]:::port end LWF <--> Sel AV <--> Sel LWF <--> Clip AV <--> Clip + LWF <--> Dnd + AV <--> Dnd + LWF <--> Refresh + AV <--> Refresh Ports["Shared ports — Avalonia-side only
edit-session · refresh · command/focus
(legacy NOT re-plumbed — throwaway avoided)"]:::port AV --- Ports diff --git a/openspec/changes/lexical-edit-avalonia-migration/canonical-view-definition-design.md b/openspec/changes/lexical-edit-avalonia-migration/canonical-view-definition-design.md new file mode 100644 index 0000000000..e917899098 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/canonical-view-definition-design.md @@ -0,0 +1,232 @@ +# Canonical Post-XML View-Definition Authoring/Storage Format (Task 9.1) + +> **Status: PROPOSAL FOR OWNER REVIEW — not decided.** Date: 2026-06-09. +> This answers design.md Open Question 1 ("C# builders, JSON/YAML, resources, database-backed +> project settings, or a hybrid?") with a layered recommendation grounded in the current +> codebase. Nothing here changes runtime behavior until tasks 9.2–9.4 implement it behind the +> existing gates. + +## 1. What "canonical format" must replace + +Today's stack, as actually built: + +- **Shipped definitions:** `DistFiles/Language Explorer/Configuration/Parts/*.fwlayout` + + `*Parts.xml`, loaded by `Inventory`/`LayoutCache` + (`Src/Common/Controls/XMLViews/LayoutCache.cs`) into merged XML node trees keyed by + `layout = {class, type, name, choiceGuid}` and `part = {id}`. +- **Customer/user overrides:** `Inventory.PersistOverrideElement` (`Src/XCore/Inventory.cs`) + writes a **whole copy of the customized `` element** into a per-project file under + the project ConfigurationSettings folder (e.g. `LexEntry.fwlayout`), stamped with + `LayoutCache.LayoutVersionNumber` (currently **27**). On load, version-mismatched overrides + are dropped or migrated by `LayoutMerger` (`Inventory.Merger`). Holding Shift at startup + deletes overrides (the documented support-recovery path). +- **Typed IR (already built):** `ViewDefinitionModel`/`ViewNode` + (`Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs`) with `StableId`, kind, + field binding, editor classification, writing system, visibility, expansion, + `CustomFieldPlaceholder` nodes, optional `LocalizationKey`/`AutomationId`/`SurfaceRouting`, + per-node diagnostics, and a deterministic `ToSnapshot()` used by parity baselines. +- **Migration tooling seed (already built):** `XmlLayoutImporter` + `DictionaryPartResolver` + + `ViewDefinitionCompiler` (cache key fingerprint, off-thread compile over immutable + `ViewDefinitionSourceSnapshot`). Measured coverage over shipped files + (`layout-import-coverage.md`): 136 detail layouts imported, 70.5% element-occurrence / + 51.1% attribute-occurrence coverage, with a full drop-diagnostic taxonomy. The 594 + jtview/publishing layouts are out of detail-lane scope. + +Two structural lessons from the legacy system drive the recommendation: + +1. **Whole-element override copies go stale.** Because an override is a full copy of the + shipped ``, every shipped layout improvement misses customized projects until + `LayoutVersionNumber` is bumped and `LayoutMerger` re-merges — a manual, lossy, whole-tree + merge. The replacement must store overrides as **sparse deltas against stable node + identity**, not copies. +2. **Custom fields are injected, not authored.** Legacy layouts mark injection points + (`customFields="here"`, ~161 `` occurrences in shipped files) and the runtime + expands them from LCModel metadata. The canonical format must keep custom fields as + **placeholder + runtime expansion**, never baked into stored definitions, or stored files + drift from each project's model. + +## 2. Options evaluated + +### Criteria + +(a) customer override story, (b) diffability/versioning, (c) custom-field injection, +(d) localization keys, (e) migration tooling from existing XML, (f) runtime loading, +(g) hand-editability for support staff. + +### Option 1: C# builder code (fluent builders compiled into the product) + +- (a) **Fails as the only store.** Customers and support staff cannot express per-project + overrides in code that requires recompilation; a second, data-based format would still be + needed — so builders cannot be *canonical*. +- (b) Excellent for shipped definitions: PR review, refactoring tools, compile-time checks + against `ViewNodeKind`/`EditorClassification`. +- (c) Natural (`AddCustomFieldPlaceholder()`). +- (d) Natural (string keys checked by tests against `.resx`). +- (e) Poor: the importer produces *data*; turning data into maintained C# source is codegen + that humans then own — a one-way, high-friction migration. +- (f) Instant (no parse), but only for shipped definitions. +- (g) None. +- **Precedent in repo:** `LexicalEditRegionBuilder.BuildFirstSliceDefinition()` was exactly + this, and task 4.10 deliberately *demoted it to a diagnosed fallback* in favor of compiling + from the layout inventory — evidence that hand-maintained builder code drifts from the + shipped contract. + +### Option 2: JSON + +- (a) Strong: sparse override/patch documents are idiomatic; schema-validated. +- (b) Strong: line-diffs well, merge tooling exists, stable when generated deterministically + (ordered keys, one node per object). +- (c) Expressible as a node kind (`"kind": "customFieldPlaceholder"`), matching the IR. +- (d) Plain fields (`"localizationKey": "..."`), matching `ViewNode.LocalizationKey`. +- (e) **Best of all options:** the importer already produces the typed IR; serializing + `ViewDefinitionModel` to JSON is a projection, and `ToSnapshot()` equality gives a + ready-made round-trip parity test. +- (f) Good: fast parse, works on net48 (System.Text.Json package or Newtonsoft.Json, both + already in the dependency universe); fits `ViewDefinitionSourceSnapshot`/`CompileAsync`/ + cache-key fingerprinting unchanged. +- (g) Adequate: readable, but no comments (mitigate with `"//"`-style description fields or + JSONC for non-shipped files) and quoting noise. + +### Option 3: YAML + +- (a)/(b)/(c)/(d) comparable to JSON in expressiveness. +- (g) Better hand-editability (comments, less punctuation) — its only advantage. +- (e)/(f) Worse: adds a parser dependency (YamlDotNet) to net48 `FwAvalonia`, which currently + has a deliberately minimal reference set enforced by `EngineIsolationAuditTests`; whitespace + sensitivity and implicit typing (the `no`/`Norway` class of surprises) are real support + hazards for the exact audience hand-editing files. +- **Verdict:** not worth a new dependency and a second wire format for a comment syntax. + +### Option 4: XML, new schema + +- (a)/(b)/(c)/(d) all expressible; XML diff/merge is familiar to this codebase. +- (e) Moderate: still requires the same IR projection work as JSON, *plus* a new schema. +- (g) Support staff know XML from `.fwlayout` — but that is also the trap: a new-schema XML + file is visually indistinguishable from legacy layout XML, inviting copy-paste of legacy + constructs (the 49%-unhandled attribute vocabulary) into the new store and making "is this + file legacy or canonical?" a recurring support question. It also undercuts the explicit + program goal ("retire XML") in a way that is hard to communicate. +- **Verdict:** workable but strictly dominated by JSON except on familiarity. + +### Option 5: Database-backed project settings (definitions in the LCModel project DB) + +- (a) Overrides would live where project data lives and travel with Send/Receive + automatically. +- (b) **Fails:** opaque to diff/review; definition changes get entangled with data-model + migrations; no PR review for shipped definitions at all. +- (e)/(f) Couples view-definition load to cache startup; breaks the off-thread immutable + snapshot compile model (task 4.6) which exists precisely to avoid live-cache reads. +- (g) **Fails:** support staff lose the two recovery moves they rely on today — inspect the + override file, or delete it (Shift-at-startup). A DB row is neither inspectable nor safely + deletable in the field. +- **Verdict:** rejected as a store. (Note: per-list `choiceGuid` layouts are *generated from* + DB content today; generation stays, but the generated artifact should be a file in the + override layer, not a DB-resident definition.) + +## 3. Recommendation: layered JSON, generated-then-owned, with stable-id patches + +**Layer 1 — Shipped definitions: deterministic JSON documents, one per +`{class, type, name}` layout, generated from the existing XML by the importer during +transition, then hand-owned after retirement.** + +- During transition (9.2–9.3) the build generates them via + `XmlLayoutImporter` → `ViewDefinitionModel` → canonical JSON serializer; generated output is + committed (not build-transient) so every shipped-definition change is a reviewable diff. +- After XML retirement the JSON files become the maintained source of truth; the XML and the + generator are deleted. A versioned JSON Schema validates them in CI. +- A thin C# builder API is retained **only** for tests and programmatic definition assembly + (the `LexicalEditRegionBuilder` fallback precedent) — it is an API over the model, not a + storage format. +- Localization stays out of the definition files: nodes carry `localizationKey` resolving to + `.resx`/Crowdin (task 6.11), exactly as `ViewNode.LocalizationKey` already models. Literal + labels are permitted only in customer overrides. + +**Layer 2 — Per-project customer overrides: sparse JSON patch documents keyed by +`ViewNode.StableId`,** stored as files in the project ConfigurationSettings folder (same +location family as today's override `.fwlayout` files, so backup/Send-Receive/support habits +carry over). + +- Patch operations, deliberately small: `setVisibility`, `setLabel`, `reorderChildren`, + `duplicateNode` (the legacy copy-with-suffix mechanism), `addNode` (customer-authored field + arrangements), `hideNode`. Anything not expressible is an explicit migration diagnostic, + never a silent drop — same policy as task 4.9. +- Each patch file carries a `formatVersion` (successor to `LayoutVersionNumber = 27`) and the + shipped-definition fingerprint it was authored against; the loader migrates or quarantines + stale patches with a user-visible diagnostic — replacing `LayoutMerger`'s whole-tree merge + with per-node, per-operation reconciliation. Patches referencing a `StableId` that no longer + exists are reported individually instead of invalidating the whole override. +- This directly fixes legacy lesson 1: shipped improvements flow to customized projects + because the base is never copied. + +**Layer 3 — Runtime expansion (not stored):** custom fields (`CustomFieldPlaceholder` + +LCModel metadata), `$ws` writing-system expansion, and per-list `choiceGuid` generated views +are expanded at compile time by `ViewDefinitionCompiler` over the immutable snapshot, exactly +as the legacy runtime expands ``/`customFields="here"`. Stored files never contain +project-model-derived nodes. + +**Explicitly rejected:** YAML (new dependency, second format, whitespace hazards), new-schema +XML (legacy/canonical confusion, undercuts retirement), database-backed definitions (opaque, +breaks snapshot compile and field recovery), C#-builders-as-canonical-store (cannot express +customer overrides; in-repo precedent 4.10 shows drift). + +### Why this is the conclusion the codebase points to + +1. The typed IR is already the runtime contract (design decision 2); the only open question is + its at-rest encoding. JSON is the lowest-friction faithful projection of + `ViewDefinitionModel`, and `ToSnapshot()` equality is a free round-trip oracle. +2. `StableId` already exists on every node and is already the key for semantic baselines + (task 2.8) — patch-by-stable-id reuses the identity scheme the test infrastructure depends + on, instead of inventing a second one. +3. The override pain that support staff live with (stale whole-copies, version-bump merges) is + a *copy-vs-delta* problem, not an *XML-vs-JSON* problem — so the layered answer (base + + sparse patches) matters more than the syntax choice, and only Options 2–4 can express it; + JSON expresses it with the fewest new moving parts. + +## 4. Migration sequence + +1. **Serializer + schema (new, feeds 9.2):** canonical JSON writer/reader for + `ViewDefinitionModel` with deterministic ordering; round-trip test = snapshot equality + against the importer's output for every shipped detail layout (the 136 already measured). +2. **Generate shipped JSON (9.2/9.3):** build step runs importer over shipped + `.fwlayout`/`*Parts.xml`, commits generated JSON, and publishes the audit report + (extension of `layout-import-coverage.md`). Dual-run gate: XML-import path and JSON-load + path must produce identical snapshots and identical `ViewDefinitionCacheKey` semantics. +3. **Override migrator (9.2/9.3):** for each project override `.fwlayout`, import both shipped + and overridden layouts to IR, diff by `StableId`, emit a JSON patch file plus a per-project + audit report listing every non-representable customization (mandatory fixtures from + `override-fixtures.md` §2 prove this on real customer overrides). +4. **Gated runtime flip (9.4):** the first migrated surface loads shipped JSON + project + patches; XML import remains available as an audit/fallback lane behind the same surface + gates (`region-manifest.md`), with rollback to XML-import resolution as the documented + recovery switch. +5. **Retirement (9.5):** delete runtime XML resolution for migrated surfaces only after the + 9.5 blocker list (custom fields, ghost items, table views, choosers, TreeView-heavy views) + is empty for that surface; non-migrated legacy surfaces keep reading XML until they are + themselves migrated — the canonical format never needs to serve legacy `DataTree`. + +## 5. Open questions for the owner + +1. **StableId durability contract:** today `StableId` derives from layout paths (task 4.10). + When shipped definitions are restructured, do we guarantee id stability by hand (review + rule + CI guard), or ship an id-remap table per `formatVersion` so old patches re-key + automatically? (Recommendation: both — guard by default, remap table when a restructure is + unavoidable.) +2. **Send/Receive merge:** do patch files get a Chorus/FLEx Bridge merge handler (field-level + merge of patch operations), or keep today's effective last-writer-wins for settings files? + Needs a product decision before customers collaborate on customized views. +3. **Scope boundary with dictionary/reversal configuration:** `/Configuration/ + Dictionary/*.xml` is a separate, already-versioned configuration system with its own + migrators. Out of scope here (this design covers Parts/Layout detail+browse definitions), + but the owner should confirm whether it eventually converges on the same patch model. +4. **Ghost items and `if`/`ifnot` conditionals:** the IR does not yet model ghost metadata or + conditional visibility (deferred from 4.1; 230 `if` occurrences in shipped files). The + canonical schema must reserve their representation *before* Layer-1 generation freezes, or + the generated files will need a breaking `formatVersion` bump early. +5. **Hand-edit ergonomics:** is plain JSON acceptable for support staff, or do we admit JSONC + (comments) for override files only, keeping shipped files strict? (Recommendation: strict + JSON everywhere; put human context in a per-file `"description"` field.) +6. **Serializer dependency on net48:** System.Text.Json (package) vs Newtonsoft.Json for + `FwAvalonia` — pick one and add it to the `EngineIsolationAuditTests` allowed-reference + set deliberately. +7. **Who owns the JSON Schema versioning policy** (when `formatVersion` bumps, who writes the + patch migrator) — proposed: same owner as `LayoutVersionNumber` bumps today. diff --git a/openspec/changes/lexical-edit-avalonia-migration/context-menu-inventory.md b/openspec/changes/lexical-edit-avalonia-migration/context-menu-inventory.md new file mode 100644 index 0000000000..c127fd953e --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/context-menu-inventory.md @@ -0,0 +1,87 @@ +# Lexical Edit Context-Menu Inventory (Section 15 work record, 2026-06-10) + +Complete inventory of the legacy right-click logic reachable from the Lexicon Edit lexical entry +detail view, produced by sub-agent sweeps of the shipped configuration (`*.fwlayout` files carry +layout part refs — earlier sweeps that filtered on `*.xml` missed them), the xCore menu engine, +and the command dispatch chain. This is the migration checklist §15 implements against. + +## 1. Menu bindings reachable from `LexEntry` detail `Normal` + +| Menu id | Bound at (file:line) | Bound via | Shown on | +|---|---|---|---| +| mnuDataTree-Sense (+ -Hotlinks) | LexSense.fwlayout:6 | `` | every sense header | +| mnuDataTree-Subsenses | LexSense.fwlayout:47 | Senses(SubSenses) part ref | subsenses section | +| mnuDataTree-VariantForms (+ -Hotlinks) | LexEntry.fwlayout:33 | VariantFormsSection part ref | Variants section | +| mnuDataTree-AlternateForms (+ -Hotlinks) | LexEntry.fwlayout:38 | AlternateFormsSection part ref | Allomorphs section | +| mnuDataTree-Help | LexEntry.fwlayout:43,48; many slices | Grammatical/Publication sections, most fields | most rows | +| mnuDataTree-Etymology (+ -Hotlinks) | LexSense.fwlayout:73 (NormalSummary); LexEntryParts.xml:29 | etymology summary/seq | Etymology | +| mnuDataTree-Pronunciation | LexEntryParts.xml:25 | Pronunciations seq | Pronunciation | +| mnuDataTree-AlternateForm | LexEntryParts.xml:119 | AlternateForms seq | individual allomorph | +| mnuDataTree-Allomorph / -AffixProcess | MorphologyParts.xml:66,79,94 | form slices | stem/affix allomorph | +| mnuDataTree-VariantForm (+Context) | LexEntryParts.xml:853; MorphologyParts.xml:297 | variant refs/form | individual variant | +| mnuDataTree-ExtendedNote (+ -Hotlinks) | LexSense.fwlayout:64 | NormalSummary | extended note | +| mnuDataTree-Examples / -Example | LexSenseParts.xml:622 | Examples seq | examples section/item | +| mnuDataTree-CitationFormContext | LexEntryParts.xml:15 | `contextMenu=` (in-string) | citation form value | +| mnuDataTree-LexemeFormContext | MorphologyParts.xml:221 | `contextMenu=` (in-string) | lexeme form value | +| mnuReorderVector | LexEntryParts.xml:824,838,844 | ref-vector slices | complex forms/subentries | +| mnuDataTree-Object | always appended (DTMenuHandler.cs:1744-1745) | — | every slice menu | +| mnuDataTree-MultiStringSlice | appended for multistring slices | — | in-string menus | + +**Add-a-sense answer:** `mnuDataTree-Sense` (DataTreeInclude.xml:442) bound on the sense's +`HeavySummary` part ref (LexSense.fwlayout:6) — items `CmdDataTree-Insert-SenseBelow` ("Insert +Sense") and `CmdDataTree-Insert-SubSense` ("Insert Subsense"); also `mnuDataTree-Sense-Hotlinks` +(:461) and `mnuDataTree-Subsenses` (:543). The Senses part ref in LexEntry.fwlayout:32 itself has +NO menu attribute — the binding lives inside the per-sense layout, so synthesized per-sense +headers must inherit the ITEM layout's root binding (15.3). + +## 2. mnuDataTree-Sense contents (DataTreeInclude.xml:442-460), in order +Insert Example · Find example sentence... · Insert Extended Note · Insert Sense · +Insert Subsense · Insert Picture (defaultVisible=false) · — · Show Sense in Concordance · — · +Move Sense Up · Move Sense Down · Demote · Promote · — · Merge Sense into... · +Move Sense to a New Entry · Delete this Sense and any Subsenses. +(Other menus' item lists recorded in the agent sweep; all ids resolve in the shipped window +configuration — proven by `Compose_EveryMenuBinding_ResolvesInTheShippedWindowConfiguration`.) + +## 3. Command dispatch chain (sense commands) + +| Command | Message | Handler | Target resolution | +|---|---|---|---| +| CmdDataTree-Insert-SenseBelow/-SubSense | DataTreeInsert | DTMenuHandler.OnDataTreeInsert (DTMenuHandler.cs:428) → Slice.HandleInsertCommand (Slice.cs:1967) | CurrentSlice(.Object/owner search) | +| CmdDataTree-Delete-Sense | DataTreeDeleteSense | LexEntryMenuHandler.OnDataTreeDeleteSense (:179) → Slice.HandleDeleteCommand | CurrentSlice.Object | +| CmdDataTree-Merge-Sense | DataTreeMerge | DTMenuHandler.OnDataTreeMerge (:1016) | CurrentSlice.Object | +| CmdDataTree-MakeSub-Sense / Promote | DemoteSense/PromoteSense | LexEntryMenuHandler (:192/:279) | CurrentSlice.Object | +| CmdDataTree-MoveUp/Down-Sense | Move{Up,Down}ObjectInSequence | DTMenuHandler (:1142/:1223) | CurrentSlice.Object | +| CmdDataTree-Split-Sense | DataTreeSplit | DTMenuHandler (:1051) | CurrentSlice.Object | + +Lexicon Edit menuHandler subclass: `SIL.FieldWorks.XWorks.LexEd.LexEntryMenuHandler` +(LexEdDll.dll), configured in Lexicon\Edit\toolConfiguration.xml:46-48. The SAME DTMenuHandler +infrastructure serves Grammar/Notebook/Lists/Words — changes here migrate those tools for free. + +**Hidden-adapter risk found:** `DTMenuHandler.OnDisplayDataTreeInsert` (DTMenuHandler.cs:865) +gates on `m_dataEntryForm.Visible` — with the hidden command-routing adapter tree, Insert +Sense/Subsense render DISABLED even though execution (which never checks visibility) would +succeed. Every other sense command checks only model state. Fix: 15.4. + +## 4. xCore menu engine recipe (for the Avalonia renderer) + +- Materialize: `new ChoiceGroup(mediator, propertyTable, adapter, List, null)` + (ChoiceGroup.cs:266) + `PopulateNow()` (:453) — fully headless, creates no WinForms UI. +- Iterate: ChoiceGroup IS an ArrayList (ChoiceRelatedClass : ArrayList, ChoiceGroup.cs:17); + members are `SeparatorChoice` (Choice.cs:679, subclass of ChoiceBase — test it FIRST), + nested `ChoiceGroup` (submenu; PopulateNow it), and `ChoiceBase` (CommandChoice, + Bool/List/StringPropertyChoice). +- Display state: `choice.GetDisplayProperties()` → `UIItemDisplayProperties` { Text ('_' marks + the accelerator), Enabled, Visible, Checked } (IUIAdapter.cs:181-237) — drives the mediator + `Display*` round-trip, i.e. the SAME enable/check logic the WinForms menu shows. +- Execute: `choice.OnClick(sender, EventArgs)` (Choice.cs:143) — CommandChoice invokes the + command through the mediator; property choices toggle the PropertyTable. +- The WinForms adapter's only routing-relevant extra is the optional TemporaryColleagueParameter + add (MenuAdapter.cs:287) — the legacy slice path passes null (DTMenuHandler.cs:1746-1749), so + the Avalonia renderer can skip it. + +## 5. Double-menu defect (live app) + +Avalonia `TextBox` ships a theme-default ContextFlyout (Cut/Copy/Paste) opened by +`ContextRequested` on right-button RELEASE; the bridge handled only `PointerPressed`, so both +the built-in flyout and the bridged menu appeared. Fix: bridged boxes get `ContextFlyout = null` +plus a handled `ContextRequested` (15.2). diff --git a/openspec/changes/lexical-edit-avalonia-migration/control-selection-matrix.md b/openspec/changes/lexical-edit-avalonia-migration/control-selection-matrix.md new file mode 100644 index 0000000000..768fc19750 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/control-selection-matrix.md @@ -0,0 +1,149 @@ +# Control Selection Matrix for Dense Tree/Table Surfaces (Task 7.6) + +> **Status: RECOMMENDED, NOT DECIDED — pending owner sign-off.** +> This matrix records the evidence and the recommended direction per surface type. No control +> choice below is a committed product decision until the owning reviewer signs off and the +> corresponding region manifest names the control as an allowed dependency. Date: 2026-06-09. + +## Scope + +Lexical Edit needs three distinct dense surfaces (design.md decisions 3 and the §5 table/tree +slice in `architecture-diagrams.md`): + +1. **Detail-view nested slices** — the `DataTree` replacement: a long, mixed-height, indented + list of labeled editors (the legacy surface flattens the conceptual tree into a sequence of + `Slice` rows with indent metadata; production-like fixtures run 68–253 slices, see task 2.13). +2. **Browse/table view** — the XMLViews `BrowseViewer` replacement: thousands to tens of + thousands of rows, column headers, sort/filter, multi-writing-system cells (task 7.1). +3. **Tree-with-translations** — TreeView-heavy chooser/semantic-domain/sense surfaces with + compact multi-writing-system node templates (task 6.4 spike). + +Candidates compared: stock Avalonia `TreeView`, `TreeDataGrid` (separate +`Avalonia.Controls.TreeDataGrid` package), `ItemsRepeater` (separate +`Avalonia.Controls.ItemsRepeater` package), `ListBox` with virtualization +(`VirtualizingStackPanel` items panel), and a FieldWorks-owned virtualized control built on +Avalonia primitives. + +Fixed constraints that shape every row (see `seam-recommendations.md` "Coexistence constraints"): + +- **Avalonia is pinned to the 11.x line until WinForms is removed.** Anything that requires + Avalonia 12 features, or a package version that only supports 12, is unavailable for the + whole coexistence phase (~1 year+). +- **TreeDataGrid went commercial in October 2025** (per `architecture-diagrams.md` §8). The last + permissively-licensed releases predate that and are effectively frozen; new fixes land on the + commercial line. +- Multi-writing-system content means per-cell/per-run font, size, and direction differences, + so every dense surface needs full data-template control inside rows/cells, and rows are + **not uniform height**. + +## Decision Matrix + +Scale: **Good** / **Partial** / **Poor** / **N/A**, with the load-bearing detail in the cell. + +| Criterion | Avalonia `TreeView` | `TreeDataGrid` | `ItemsRepeater` | `ListBox` + virtualization | FieldWorks-owned virtualized control | +|---|---|---|---|---|---| +| **Information density control** | Partial — templated items, but per-level container chrome (expander margins, item padding) is themed for general UI, needs heavy restyling to hit FLEx density | Good — designed for dense rows; row height and cell padding controllable | Good — no chrome at all; layout is whatever the template says | Partial/Good — item container chrome must be restyled (padding, selection visuals), then density is template-controlled | Good — density is a first-class design input (DPI density measurement is already an open spike item) | +| **Virtualization** | **Poor — `TreeView` does NOT virtualize.** Expanded hierarchies realize every container; unusable for unbounded trees and a known perf cliff on the 11.x line | Good — row virtualization for both flat and hierarchical sources is its core feature | Partial — virtualizing layouts exist, but recycling with mixed-height items and bring-into-view behavior need per-surface validation | Good — `VirtualizingStackPanel` is the stock, maintained virtualization path in 11.x; flat lists only (hierarchy must be flattened in the model) | Good (by construction) — built over `VirtualizingStackPanel`/`ItemsControl` primitives or an owned realization window; cost is ours | +| **Cell/row templating for multi-WS content** | Good — full `TreeDataTemplate` per node; compact multi-WS node templates are possible (the 6.4 spike) | Partial — `TemplateColumn` allows arbitrary cell content, but the column model assumes per-column uniformity; per-row WS-driven structure variance fights the model; editing templates are weak | Good — arbitrary templates, nothing imposed | Good — arbitrary `DataTemplate` per item; a slice row template can host any FieldWorks editor control | Good — templates and the editor registry (`ILexicalEditorRegistry`) are the design center | +| **Selection model** | Partial — single selection solid; multi-select and programmatic selection of unrealized nodes are weak spots | Good — flat/hierarchical selection models including multi-select and cell selection | **Poor — none.** No selection concept; everything hand-built | Good — stock single/multiple selection, `SelectionModel`, programmatic selection works with virtualization | Partial — must be built, but can implement exactly the legacy semantics (current-slice highlight, browse-row check/multi-select) over a managed selection service (diagram §5 "Managed selection") | +| **Accessibility / automation peers** | Good — stock peers expose tree item pattern, expand/collapse, names; works with `AutomationProperties.AutomationId` stamping (6.9) | Partial/Poor — automation peer coverage is a known weak area (noted in `architecture-diagrams.md` §8: "weak editing/accessibility"); gaps would have to be patched on a commercial codebase | **Poor — no items-control peers**; every UIA pattern hand-written | Good — stock `ListBox`/`ListBoxItem` peers, selection patterns, scroll patterns work today and are what task 7.11 UIA parity evidence would exercise | Partial — peers must be authored, but FieldWorks controls already stamp stable automation metadata (`PocLexEntrySliceTests`), and owning the peers means the 7.11 automation-tree parity gate is satisfiable by design rather than by upstream goodwill | +| **Keyboard navigation** | Good — arrows/expand/collapse/typeahead stock | Partial — grid navigation present; editing-mode keyboard flow (F2/Enter/Tab-through-cells) is immature | **Poor — none** | Good — stock up/down/home/end/page/typeahead; left/right and expand/collapse for a flattened tree must be added at the surface level | Partial — built to match legacy DataTree/Browse keyboard contracts exactly (which stock controls would also need overriding to achieve) | +| **Licensing / version availability on pinned 11.x** | Good — in-box, MIT, every 11.x release | **Poor — commercial since Oct 2025.** Staying free means pinning a frozen pre-commercial MIT version with no fixes for the entire coexistence phase; paying adds procurement + redistribution questions for an open-source FLEx | Partial — MIT, but **upstream is retiring/deprecating it** (diagram §8); it ships separately from core and gets minimal attention on 11.x | Good — in-box, MIT, the most-exercised virtualization path in the framework | Good — our license, our timeline; only depends on in-box primitives that survive the 11.x→12 jump | +| **Maintenance risk** | Low (control) / High (consequence) — control is maintained, but the no-virtualization design forces item-count caps forever | High — single-vendor commercial dependency, or frozen MIT fork we patch ourselves (a hard fork in all but name, contrary to proposal.md dependency guidance) | High — deprecated upstream; building on it guarantees a forced rewrite | Low — core control, used by everything, survives framework upgrades | Medium — we own the code (cost), but it is small code over stable primitives, and FieldWorks-owned dense controls are already the stated direction (design.md decision 3) | + +### Notes on candidates not in the matrix + +- **Avalonia `DataGrid`** (separate `Avalonia.Controls.DataGrid` package) was considered for the + browse surface but is excluded from the matrix: it is a port with long-standing virtualization + and editing bugs, is in maintenance mode upstream, and offers nothing over + `ListBox`-with-owned-headers for our template-heavy cells. It can be revisited if upstream + investment resumes. +- **`TreeView` + manual flattening** is not a distinct option: once the hierarchy is flattened + into the items source, the control *is* a `ListBox` with indent templating — which is the + `ListBox` column. + +## Recommendation per Surface Type + +> All three recommendations follow one principle: **flatten in the model, virtualize with stock +> primitives, own the row.** The typed IR already supports this — `ViewNode` carries `Indented`, +> `Expansion`, and `StableId`, and the legacy `DataTree` itself renders a *flattened* slice +> sequence, so a flat virtualized list with indent metadata is the faithful projection of the +> legacy surface, not a compromise. + +### 1. Detail-view nested slices (DataTree replacement) + +**Recommended: FieldWorks-owned slice list control built over `ListBox`/`ItemsControl` + +`VirtualizingStackPanel`** (i.e., the "ListBox with virtualization" column hardened into an +owned control). The region model already projects the IR tree to a flat sequence; expansion +state lives in the model (`ViewExpansion`), indent is template data, and each row's editor +comes from the editor registry. Mixed row heights (multi-WS multistring slices) are the main +virtualization risk — validate `VirtualizingStackPanel` estimated-size/bring-into-view behavior +against the 253-slice fixture before committing (feeds the 7.7 budgets; legacy baseline: +2483 ms open / 253 slices, budget rule in `region-manifest.md` §5). + +- `TreeView`: rejected — no virtualization, and the surface is not visually a tree anyway. +- `TreeDataGrid`: rejected — no columns here, licensing cost buys nothing. +- Raw `ItemsRepeater`: rejected — deprecated upstream; no selection/peers. + +### 2. Browse/table view (XMLViews replacement, task 7.1) + +**Recommended: FieldWorks-owned virtualized table — flattened row list over +`VirtualizingStackPanel` with an owned shared-scope column header bar and owned cell layout +(uniform column grid via a lightweight panel), stock `ListBox` selection.** Row counts (10k+) +make virtualization non-negotiable; multi-WS cells make template control non-negotiable; the +filter bar/header reachability already baselined by `WinFormsUiaSmokeTests` must be +reproducible, which favors owned peers over `TreeDataGrid`'s weak ones. + +- `TreeDataGrid` is the *technically* closest stock fit (it is the only candidate with columns + + virtualization out of the box) and remains the named **pivot option** below — its rejection + here is driven by the Oct 2025 commercial licensing on the pinned 11.x line plus weak + editing/accessibility, not by capability. +- `ListBox` alone: insufficient — no column infrastructure; that is exactly the part we own. + +### 3. Tree-with-translations (choosers, semantic domains, sense trees — task 6.4) + +**Split recommendation by boundedness:** + +- **Bounded popup trees** (morph-type chooser, small possibility lists; tens to a few hundred + nodes): **stock `TreeView` with compact multi-WS `TreeDataTemplate`s**, with an explicit, + manifest-recorded item-count ceiling (proposed: ≤ 500 realized nodes) because `TreeView` does + not virtualize. This is the cheapest path to chooser parity (6.3) and keeps stock + accessibility/keyboard behavior. +- **Unbounded or large trees** (full semantic domain list ~1800+ nodes with translations, + reversal trees): **the owned flattened virtualized list from surface 1, plus + expander/indent row chrome** — same control, tree-shaped projection, model-owned expansion. + +The 6.4 spike should explicitly measure where the `TreeView` ceiling really is on 11.x at +100%/150% DPI before the ceiling number is frozen. + +## Pivot Triggers + +Revisit this matrix (and re-run the spike evidence) if any of the following occurs: + +1. **TreeDataGrid licensing changes** — relicensed permissively, a maintained community fork + emerges, or SIL accepts the commercial license **and** upstream closes the editing + + automation-peer gaps: re-evaluate it for the browse/table surface before building owned + column infrastructure (or to replace it if ours underperforms). +2. **`VirtualizingStackPanel` fails the mixed-height fixtures** — scroll/expand or open-time + budgets (7.7) miss on the 253-slice or 10k-row fixtures, or bring-into-view/focus-restore + misbehaves with variable row heights: escalate to a fully owned realization-window + virtualizer (the "owned control" column without stock panel reuse). +3. **`TreeView` gains virtualization on a consumable 11.x release** (or the post-WinForms + Avalonia 12 upgrade lands with it): raise/remove the bounded-tree ceiling and reconsider + `TreeView` for large trees. +4. **`ItemsRepeater` is un-deprecated** with a maintained virtualization story: reconsider as + the substrate for the owned controls (it would reduce owned layout code). +5. **Owned-control cost overruns** — if the owned table's selection/peers/keyboard work exceeds + the spike budget materially, re-open the TreeDataGrid commercial option with the actual + measured cost as the comparison baseline rather than an estimate. +6. **Accessibility gate failure** (task 7.11) on any stock control we adopted: owning the + surface's peers becomes mandatory, which collapses the choice back to the owned column. + +## Relationship to other artifacts + +- Task 6.4 (tree-with-translations spike) and 7.1 (virtualized table path) consume this + matrix's recommendation and produce the evidence that confirms or pivots it. +- Region manifests (design decision 8) must name the chosen control per surface as an allowed + dependency once the owner signs off. +- Performance budgets in `region-manifest.md` §5 (and 2.13 measured baselines) are the + acceptance bar for whichever control is chosen — the control choice never relaxes the budget. diff --git a/openspec/changes/lexical-edit-avalonia-migration/design.md b/openspec/changes/lexical-edit-avalonia-migration/design.md index e592d351d9..e2b7354356 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/design.md +++ b/openspec/changes/lexical-edit-avalonia-migration/design.md @@ -21,7 +21,7 @@ Important current constraints: - Introduce typed view-definition and Presentation IR interfaces suitable for dependency injection, semantic parity tests, and Avalonia rendering. - Make the lexical-edit UI mode an app-wide product switch while keeping each current `RecordEditView` consumer on an explicit contract: supported Avalonia surface, explicit legacy fallback, or explicit blocked state. - Preserve interaction behavior, information density, writing-system fonts, OpenType/HarfBuzz shaping behavior, nested structures, popup choosers, table views, and TreeView-heavy views. -- Decommission native Graphite/rendering from the default Lexical Edit path: Graphite work starts when the migration starts, and Avalonia does not become the default screen until Graphite dependencies are classified and either replaced, retained behind legacy fallback/export boundaries, or blocked with explicit diagnostics and rollback. +- Keep the Avalonia path free of native Graphite/Views shaping engines (audited per region), while Graphite *support* follows `graphite-transition-support` (2026-06-09): fully supported on legacy surfaces until the M2 sunset milestone, classified G0–G3 and warned (not blocked) on Avalonia surfaces, removed at M3 with WinForms. - Decommission C++ viewing/rendering dependencies by migrated region so completed Avalonia regions do not use native Views, `RootSite`, `IVwEnv`, `ManagedVwWindow`, or equivalent C++ display/layout/editor infrastructure at runtime. Custom linguistics services may remain when they are exposed through explicit service seams and do not own Avalonia viewing or editing surfaces. - Extend render verification to capture semantic output, not only pixels and timings. - Keep Avalonia code and tests on the normal repo build/test path; build strategy must not become the way we select legacy vs Avalonia behavior. @@ -89,7 +89,16 @@ Important current constraints: ### 7. Graphite and native rendering are evidence-gated before Avalonia becomes default -**Decision:** Graphite/native-rendering decommissioning begins at the start of the Lexical Edit Avalonia migration. The default Avalonia Lexical Edit path must not depend on native Graphite render engines, Gecko Graphite rendering, or unclassified Graphite-only feature settings. Legacy fallback/export consumers may remain only when explicitly classified outside the migrated default path. +> **Revised (2026-06-09) — see `graphite-transition-support`.** Graphite support policy moved to its +> own change and inverted from "retire before default" to "support through transition with a graded +> warning": Graphite remains fully supported on legacy surfaces until the M2 mid-decommissioning +> milestone, Avalonia surfaces render Graphite-affected writing systems with OpenType shaping plus a +> per-WS G0–G3 classified warning (never a hard block, never a silent change), and removal happens at +> M3 with WinForms. What survives of this decision here: the **engine isolation** rule (the Avalonia +> path never loads native Graphite/Views shaping — audited per region manifest) and the Gecko/PDF +> classification scope below. + +**Decision (as revised):** The default Avalonia Lexical Edit path must not depend on native Graphite render engines, Gecko Graphite rendering, or unclassified Graphite-only feature settings — but Graphite *presence* in a project does not block an Avalonia default; warning/classification coverage per `graphite-transition-support` is the gate. Legacy fallback/export consumers may remain when explicitly classified outside the migrated default path. **Rationale:** Avalonia documents custom TrueType/OpenType fonts and OpenType `FontFeatures`, but that does not prove FieldWorks Graphite parity. HarfBuzz Graphite2 shaping requires HarfBuzz to be built with Graphite2 enabled, and HarfBuzz documentation says that support is not enabled by default. Graphite behavior therefore needs fixture evidence, not assumption. @@ -206,7 +215,7 @@ Pick a representative lexical path, such as LexEntry morph type plus nested sens - XML import drift from legacy behavior -> Mitigate with semantic snapshots and parity tests against production layouts and user-override fixtures. - Refresh protocol regressions -> Extract/cover refresh coordination before UI replacement. - TreeView/table complexity -> Spike dense custom item templates, TreeDataGrid license/version implications, and owned virtualized row templates early. -- Graphite/native rendering decommissioning -> Begin the inventory at migration start and block Avalonia default until Graphite engines, feature UI/storage, sample fonts, Gecko Graphite prefs, PDF/export assumptions, and tests/docs are classified, replaced, moved behind legacy boundaries, or blocked with diagnostics. +- Graphite rendering differences on Avalonia surfaces -> Governed by `graphite-transition-support` (2026-06-09): legacy surfaces keep full Graphite support until M2; Avalonia surfaces classify each writing system (G0–G3) and warn instead of blocking; the engine-isolation audit and Gecko/PDF classification remain here. - Gecko/browser rendering -> `XWebBrowser`, dictionary/configuration previews, interlinear configuration previews, print, and `GeckofxHtmlToPdf` need a non-Graphite replacement or an explicit non-default legacy boundary. - PropertyGrid limits -> Treat it as a prototype path; do not let it define the final IR or UI shape. - Automation flakiness -> Keep UIA2 tests thin; use model/semantic assertions for deep behavior. @@ -222,7 +231,7 @@ Pick a representative lexical path, such as LexEntry morph type plus nested sens 2. Define the app-wide lexical-edit UI mode contract and explicit per-host behavior matrix before expanding product wiring beyond the first hosts. 3. Introduce DI-friendly services around DataTree refresh, view-definition source/import/compile/cache, editor selection, command/property/navigation state, edit sessions, UI dispatch, lifetime, LCModel access, and launcher logic, following `avalonia-ui-scheduler`, `avalonia-lifetime`, and the local phase of `avalonia-command-focus`. 4. Keep Avalonia build/test integration on the normal repo scripts while the runtime UI mode remains the only product selection mechanism. -5. Start Graphite/native rendering decommissioning: inventory affected project settings, fonts, render engines, Gecko/PDF paths, tests, docs, and build artifacts; prove no default-path claim depends on unverified Graphite behavior. +5. Execute Graphite transition per `graphite-transition-support`: ship the G0–G3 writing-system classification and graded Avalonia warning with the first user-text Avalonia surface; keep legacy-surface Graphite untouched; prove engine isolation on the Avalonia path; classify Gecko/PDF paths here (tasks 5.6/5.7). 6. Define migrated-region manifests and hard gates for each proposed Avalonia region. 7. Extend render verification with normalized semantic snapshots, visual/timing evidence, performance budgets, and failure bundles. 8. Build typed view-definition and XML import as the compatibility compiler. diff --git a/openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md b/openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md new file mode 100644 index 0000000000..7685e9f80d --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md @@ -0,0 +1,43 @@ +# Dialog Ownership and Modality Across the Interop Boundary (Task 3.16) + +Rules for dialogs and popups while WinForms and Avalonia surfaces coexist in one process and one +window (Avalonia 11.x hosted via `WinFormsAvaloniaControlHost`). One UI thread, one message loop: +WinForms modality (`ShowDialog`) blocks the shared loop, which is what makes the rules below workable. + +## Rules + +1. **WinForms modal dialogs launched from an active Avalonia surface** (choosers, message boxes, + options): always pass an owner — the hosting WinForms top-level form (`Control.FindForm()` of the + `PocWinFormsHostControl`/host), never `null` and never an Avalonia handle. This guarantees + correct z-order and minimize/restore grouping. Modality is process-wide on the shared loop, so + the Avalonia surface is implicitly blocked — no extra disable/enable dance. +2. **Focus return**: the launcher records the focused Avalonia control before `ShowDialog` and + restores focus to it (via the host's focus API) after the dialog closes. Cross-boundary Tab is + unreliable on 11.x (AvaloniaUI/Avalonia#12025); explicit restore-after-dialog is the supported + pattern, never "let focus find its way back". +3. **Avalonia flyouts/popups inside the WinForms-hosted surface**: prefer flyouts attached to the + triggering control (the POC chooser pattern). Account for the known 11.x popup-DPI quirk on + mixed-DPI monitors: test popup placement at 100%/150%, and prefer `Flyout` over free `Popup` + windows — flyouts position in-surface and avoid the worst of it. +4. **No Avalonia modal windows during coexistence.** Avalonia `Window.ShowDialog` against a WinForms + owner is not a supported combination on 11.x in this app; anything needing modality uses a + WinForms dialog (rule 1) until the shell phase. +5. **Message boxes** from Avalonia-side code route through the existing FieldWorks message adapter + (`SetMessageBoxAdapter` test seam), same as legacy code — keeps tests headless-safe. + +## Explicitly unsupported during coexistence + +- Avalonia-owned modal windows (rule 4). +- WinForms modeless tool windows owned by an Avalonia surface (no owner handle to give them) — + modeless tools stay owned by the main window. +- Cross-boundary Tab order between WinForms siblings and the Avalonia surface (own the focus + *inside* the surface; coarse hosting is the standing constraint). + +## Verification + +- Covered now: focus-return contract at the seam level (`IHostSurface` focus API, + `HostSurfaceContractTests`); popup focus return inside the surface (`PocLexEntrySliceTests`). +- Still open (needs a realized-window UIA run, not headless): chooser-launch-from-Avalonia smoke — + launch a WinForms chooser from the Avalonia surface, assert owner is the host form, dismiss, + assert focus returns to the launching Avalonia control. Lands with the first real chooser + integration (6.3), driven through the `WinFormsUiaSmokeTests` harness. diff --git a/openspec/changes/lexical-edit-avalonia-migration/gecko-pdf-audit.md b/openspec/changes/lexical-edit-avalonia-migration/gecko-pdf-audit.md new file mode 100644 index 0000000000..470b0fa0a8 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/gecko-pdf-audit.md @@ -0,0 +1,153 @@ +# Gecko/XULRunner Browser and PDF Audit (Tasks 5.6 / 5.7) + +Date: 2026-06-09 +Branch: `010-advanced-entry-view-phase-1-2` +Scope: task 5.6 (audit of Gecko/XULRunner preview, print, and PDF paths) and task 5.7 +(non-Gecko browser/PDF strategy — **proposal only, owner sign-off required**). + +All file:line references below were verified by grep/read on this branch on 2026-06-09. + +--- + +## Inventory (5.6) + +### Startup initialization and the Graphite preference + +Gecko/XULRunner is initialized unconditionally in `FieldWorks.exe` `Main`, **before any window +opens**, and a missing Firefox folder is a hard startup failure: + +- `Src/Common/FieldWorks/FieldWorks.cs:164-192` — `#region Initialize XULRunner`: resolves the + `Firefox`/`Firefox64` folder (or `XULRUNNER` env var), calls `Xpcom.Initialize(firefoxPath)`. +- `Src/Common/FieldWorks/FieldWorks.cs:175-180` — throws `ApplicationException` ("Cannot find + Firefox/XulRunner directory") if the folder is absent. **Gecko is currently a hard startup + dependency of the entire app**, independent of whether any browser surface is ever shown. +- `Src/Common/FieldWorks/FieldWorks.cs:187` — `GeckoPreferences.User["gfx.font_rendering.graphite.enabled"] = true;` + (the Graphite preference this audit was asked to locate). +- `Src/Common/FieldWorks/FieldWorks.cs:188` — `print.show_print_progress = false`. +- `Src/Common/FieldWorks/FieldWorks.cs:191` — `XWebBrowser.DefaultBrowserType = XWebBrowser.BrowserType.GeckoFx;` + makes every default-constructed `XWebBrowser` in the app a Gecko browser. +- `Src/Common/FieldWorks/FieldWorks.cs:399-409` — shutdown hack: constructs a throwaway + `GeckoWebBrowser` (line 407) before `Xpcom.Shutdown()` to avoid a native double-free. + +### Consumer inventory + +Classification key: +- **(a)** reachable from default Lexical Edit workflows (lexiconEdit tool and its dialogs) +- **(b)** reachable from export/print only +- **(c)** dev/test only (or latent: no shipped configuration reaches it) +- **(d)** reachable from other areas/tools in normal navigation, but outside the Lexical Edit + migrated-region boundary (added for honesty; the migration boundary does not own these) + +| Area | Source (file:line) | Role | Classification | +|---|---|---|---| +| App startup XULRunner init + Graphite pref | `Src/Common/FieldWorks/FieldWorks.cs:164-192` (pref at `:187`, hard fail at `:175-180`) | Process-wide Gecko bootstrap; required before any window | (a) — runs in every workflow, including Lexical Edit | +| App shutdown Gecko hack | `Src/Common/FieldWorks/FieldWorks.cs:399-409` | Throwaway `GeckoWebBrowser` + `Xpcom.Shutdown()` | (a) — every process exit | +| Dictionary preview pane inside Lexicon Edit | `Src/xWorks/XhtmlRecordDocView.cs:67` (`new XWebBrowser(BrowserType.GeckoFx)`); wired by `DistFiles/Language Explorer/Configuration/Lexicon/Edit/toolConfiguration.xml:36-41` including `Dictionary/toolConfiguration.xml:4-6` (`DictionaryPubPreviewControl`); on by default per `Lexicon/areaConfiguration.xml:308` (`Show_DictionaryPubPreview` bool=true) | "Show Dictionary Preview" pane rendered above the entry pane in the lexiconEdit tool | (a) — visible by default in Lexical Edit | +| Lexicon Edit File > Print | `Src/xWorks/XhtmlRecordDocView.cs:135-139` → `XhtmlDocView.PrintPage` at `Src/xWorks/XhtmlDocView.cs:963-967` (`geckoBrowser.Window.Print()`); `defaultPrintPane="DictionaryPubPreview"` at `Lexicon/Edit/toolConfiguration.xml:9` | Printing from Lexical Edit prints the Gecko preview pane via Gecko's print dialog | (b) — print only | +| Dictionary doc view (Lexicon > Dictionary tool) | `Src/xWorks/XhtmlDocView.cs:59` (`new XWebBrowser(BrowserType.GeckoFx)`); tool wiring `Lexicon/Dictionary/toolConfiguration.xml:27`; record view `Lexicon/Dictionary/toolConfiguration.xml:5` | Full configured-dictionary XHTML view, DOM click/right-click handling (`XhtmlDocView.cs:289,315,497`) | (d) — Lexicon area navigation, outside lexiconEdit region | +| Reversal Indexes doc view | `DistFiles/.../Lexicon/ReversalIndices/toolConfiguration.xml:12` → `XhtmlDocView` | Reversal index XHTML doc view | (d) | +| Classified Dictionary view | `DistFiles/.../Lexicon/RDE/toolConfiguration.xml:213` → `XhtmlDocView` | Semantic-domain classified dictionary XHTML view | (d) | +| Dictionary print → PDF (large dictionaries) | `Src/xWorks/XhtmlDocView.cs:849-925`; exe name `:885`, missing-exe error `:900`, invocation `:907` (`FieldWorksPdfMaker.exe "" "" --graphite --reduce-memory`), open in OS viewer `:923` (`Process.Start(outputFile)`) | `OnPrint` over `defaultMaxEntriesFWCanPrint` (10000) entries generates a PDF via `GeckofxHtmlToPdf` and hands it to the OS viewer to print | (b) — print only | +| Dictionary print (in-browser path) | `Src/xWorks/XhtmlDocView.cs:927-961` (`GenerateReloadAndPrint`) and `:963-967` (`PrintPage`) | Smaller dictionaries print directly through Gecko `Window.Print()` | (b) — print only | +| Dictionary Configuration dialog preview | `Src/xWorks/DictionaryConfigurationDlg.Designer.cs:55` (`m_preview = new XWebBrowser()`); hard Gecko requirement `Src/xWorks/DictionaryConfigurationDlg.cs:44-49` and `:207-211`; highlight logic over `GeckoElement` `:228` | Tools > Configure > Dictionary preview pane; launched from `Src/xWorks/DictionaryConfigurationListener.cs:220` and from doc-view right-click at `Src/xWorks/XhtmlDocView.cs:630` | (a) — the configure-dictionary dialog is reachable from Lexicon Edit's Tools menu | +| New Entry dialog gloss assistant (MGA) | `Src/LexText/Morphology/MGA/MGAHtmlHelpDialog.cs:19,30` (`GeckoWebBrowser m_browser`); launched from `Src/LexText/LexTextControls/InsertEntryDlg.cs:1832-1836` | EticGlossList HTML help pane in the Morphological Gloss Assistant, opened from the Insert Entry dialog's "Gloss assistant" link (inflectional MSA only) | (a) — reachable from Lexical Edit Insert Entry | +| Configure Interlinear dialog | `Src/LexText/Interlinear/ConfigureInterlinDialog.Designer.cs:28` (`mainBrowser = new XWebBrowser()`); hard Gecko requirement `ConfigureInterlinDialog.cs:74-78`; `AutoJSContext` JS interop `:764,791`; Gecko DOM checkbox manipulation `:785,803` | Interlinear row-configuration preview; launched from `Src/LexText/Interlinear/InterlinDocRootSiteBase.cs:855` and `Src/LexText/Discourse/ConstituentChart.cs:110` | (d) — Texts & Words / Discourse only | +| Grammar Sketch viewer | `Src/xWorks/GeneratedHtmlViewer.cs:206-219` (`InitHtmlControl` → XCore `HtmlControl`); Gecko Find dialog `GeneratedHtmlViewer.cs:1053-1056`; tool wiring `DistFiles/.../Grammar/Edit/toolConfiguration.xml:398` (tool `grammarSketch`) | Generated morphological sketch HTML viewer | (d) — Grammar area only | +| XCore `HtmlControl` (shared Gecko wrapper) | `Src/XCore/HtmlControl.cs:20,38,265` (`m_browser = new GeckoWebBrowser()`) | Reusable Gecko host used by GeneratedHtmlViewer, TryAWordDlg, HtmlViewer, FLExBridge instructions dialog | (d) — classification follows its consumers | +| Parser Try A Word / parser trace | `Src/LexText/ParserUI/TryAWordDlg.cs:56,152-154` (`m_htmlControl = new HtmlControl`); Gecko DOM interop in `Src/LexText/ParserUI/WebPageInteractor.cs:18-59`; launched from `Src/LexText/ParserUI/ParserListener.cs:1176` | Parser results/trace HTML with clickable Gecko elements | (d) — Texts & Words parser menu | +| Send/Receive first-time instructions | `Src/LexText/Lexicon/FLExBridgeFirstSendReceiveInstructionsDlg.Designer.cs:42` (`new XCore.HtmlControl()`) | Static instruction HTML before first Send/Receive | (d) — Send/Receive workflow | +| SFM Import wizard marker help | `Src/LexText/LexTextControls/LexImportWizardMarker.cs:77,485` (`GeckoWebBrowser m_browser`); help file path `:80` (`Language Explorer/Import/Help.htm`) | Embedded help pane in the lexical SFM import wizard | (d) — import workflow | +| XCore `HtmlViewer` content control | `Src/XCore/HtmlViewer.cs:26,59` (`m_htmlControl = new HtmlControl()`) | Generic xCore HTML content control; **no shipped `DistFiles` configuration references it** (verified by grep) | (c) — latent infrastructure | +| `ReallySimpleListChooser` help-browser pane | `Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs:124,933-935,1046-1083` (`m_webBrowser = new XWebBrowser()` at `:1057`) | Optional chooser help pane gated on `chooserInfo helpBrowser="true"`; both shipped configs set it **false** (`DistFiles/.../Parts/CellarParts.xml:237`, `NotebookParts.xml:61`). Note: the non-Mono branch at `:1071-1074` casts `NativeBrowser as WebBrowser` (WinForms/IE), which is null under the app-wide GeckoFx default — a latent NRE if ever enabled on Windows | (c) — latent / config-gated, no shipped consumer | +| Forbidden-symbol enforcement (FwAvalonia) | `Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs:30,41` and `LexicalEditSurfaceResolverTests.cs:101`; manifest list `openspec/changes/lexical-edit-avalonia-migration/region-manifest.md:96` | Tests asserting `FwAvalonia` references no Gecko/`XWebBrowser`/`GeckofxHtmlToPdf`/`FieldWorksPdfMaker` symbols (task 5.5) | (c) — dev/test enforcement | + +### `GeckofxHtmlToPdf` / `FieldWorksPdfMaker` usage and packaging + +- **Single runtime call site.** `FieldWorksPdfMaker.exe` is invoked only from + `Src/xWorks/XhtmlDocView.cs:882-925` (`GeneratePdfToPrint`): the *print* path for dictionaries + larger than 10000 entries (`:851,861-869`) or after a COM print failure + (`:952-957` directs users to the env-var/PDF route). The PDF is opened in the OS viewer + (`Process.Start`, `:923`) — printing itself is already delegated to the OS at that point. + No File > Export path uses it (verified: no other `FieldWorksPdfMaker`/`GeckofxHtmlToPdf` + reference under `Src/` outside `XhtmlDocView.cs`, its resources + `Src/xWorks/xWorksStrings.resx:1100` / `xWorksStrings.Designer.cs:2624-2626`, and the + FwAvalonia forbidden-symbol tests). +- **Packaging.** `Build/PackageRestore.targets:31-32` pins `GeckofxHtmlToPdfVersion 1.1.0`; + `:259-273` downloads `GeckofxHtmlToPdf.exe(.config)` + `Args.dll` from the sillsdev GitHub + release; `:477-491` copies it into the output as **`FieldWorksPdfMaker.exe`** (renamed so users + can identify it in antivirus quarantine); `:493-520` patches its `Geckofx-Core` binding redirect + to match `GeckoNugetVersion` (`Build/SilVersions.props:24` = `60.0.56`); `:556-570` copies the + Geckofx60 NuGet `content/` (the native XULRunner `Firefox64` folder) into the output base. +- **Installer.** `Build/Installer.legacy.targets:670,716` suppress ICE30 because FieldWorks and + the Encoding Converters merge module both install Geckofx files. +- **Assembly references.** `Geckofx60.64` is referenced by `Src/Common/FieldWorks/FieldWorks.csproj:52`, + `Src/xWorks/xWorks.csproj:45-46`, `Src/XCore/xCore.csproj:29`, `Src/Common/Controls/XMLViews/XMLViews.csproj:34`, + `Src/LexText/Interlinear/ITextDll.csproj:36,43` (plus `SIL.Windows.Forms.GeckoBrowserAdapter`), + `Src/LexText/ParserUI/ParserUI.csproj:31`, `Src/LexText/Morphology/MGA/MGA.csproj:30`, and + `Src/LexText/LexTextControls/LexTextControls.csproj:36-37`. + +--- + +## Graphite coupling + +1. **App-wide rendering preference.** `Src/Common/FieldWorks/FieldWorks.cs:187` turns on + `gfx.font_rendering.graphite.enabled` for every Gecko surface above. Any Graphite-enabled + writing system whose font carries Graphite tables is shaped by Gecko's Graphite engine in the + Lexicon Edit dictionary preview, the Dictionary/Reversal/Classified doc views, the + configuration-dialog previews, and the interlinear configuration preview. +2. **PDF shaping.** `Src/xWorks/XhtmlDocView.cs:907` passes `--graphite` to + `FieldWorksPdfMaker.exe`, so the print-to-PDF path also shapes with Graphite (the PDF maker is + itself Geckofx-based). +3. **Replacement consequence.** Graphite shaping is a Firefox/Gecko-specific capability; Chromium + (and therefore WebView2) has no Graphite support. Any non-Gecko browser/PDF replacement loses + Graphite shaping on these surfaces by construction. That is consistent with the + `graphite-transition-support` change (warn-don't-block, sunset policy), but every replaced + surface must be covered by that change's classification + warning machinery before the swap. +4. **Boundary enforcement already in place.** The migrated Avalonia region cannot acquire a Gecko + or Graphite dependency silently: `EngineIsolationAuditTests.cs:30,41` fails the suite if + `FwAvalonia` references `Gecko*`, `XWebBrowser`, `GeckofxHtmlToPdf`, or `FieldWorksPdfMaker` + (whole-identifier match), per `region-manifest.md:96`. + +--- + +## Strategy options (5.7) + +> **Status: PROPOSAL — recommended, not decided.** This section is a recommendation for owner +> sign-off; no replacement work is started by this document. + +| # | Option | Scope | Effort | Risk | Graphite impact | Notes | +|---|---|---|---|---|---|---| +| 1 | **WebView2** (Microsoft.Web.WebView2, WinForms control) | Replace `XWebBrowser`/`GeckoWebBrowser` in the (a)/(b) surfaces: dictionary preview pane, doc views, configuration-dialog previews; replace `GeckofxHtmlToPdf` with `CoreWebView2.PrintToPdfAsync` | Medium-high: ~10 consumer surfaces; the hard parts are the Gecko DOM/JS interop ports (`DictionaryConfigurationDlg.cs:228` GeckoElement walking, `ConfigureInterlinDialog.cs:764-803` `AutoJSContext` + `GeckoInputElement`, `XhtmlDocView.cs:497` DOM right-click menus, `WebPageInteractor.cs:32-59`) to `ExecuteScriptAsync`/WebMessage patterns | Medium: Windows-only (fits the current net48 Windows-only target); requires the Evergreen WebView2 runtime (installer bootstrapper or fixed-version distribution); behavior parity for print CSS must be re-validated | **Loses Graphite shaping** — requires `graphite-transition-support` classification + warnings on each converted surface | The natural end state for Windows; also removes the startup hard dependency once `FieldWorks.cs:164-192` is made lazy | +| 2 | **Keep Gecko as a classified legacy-export/preview boundary** | No code change; all (a)/(b)/(d) consumers stay on Gecko; the Avalonia Lexical Edit region continues to forbid Gecko symbols (already enforced, task 5.5) | Low | Low short-term; long-term: Gecko 60 (2018, security-frozen) keeps shipping; XULRunner stays a hard startup dependency (`FieldWorks.cs:175-180`) | Graphite rendering preserved on legacy surfaces — consistent with `graphite-transition-support` legacy-surface support until M2 | Correct posture for the first migrated slices; insufficient as the end state for an Avalonia *default* | +| 3 | **Print via the OS** (PDF hand-off) | Generalize the existing pattern at `XhtmlDocView.cs:923` (`Process.Start(pdf)`): generate a PDF (engine per option 1 or 2) and let the OS viewer own the print dialog, retiring `Gecko Window.Print()` (`XhtmlDocView.cs:963-967`, `XhtmlRecordDocView.cs:135-139`) | Low (on top of whichever PDF engine is chosen) | Low; removes the flaky in-browser print paths documented at `XhtmlDocView.cs:940-946,952-957` | Follows the PDF engine's shaping | Recommended regardless of engine choice | +| 4 | Avalonia-native preview rendering of typed IR (no embedded browser for the lexiconEdit preview pane) | Replace `XhtmlRecordDocView` preview with an Avalonia render of the dictionary-publication IR | High | High; duplicates the configured-dictionary CSS pipeline | Follows the 6.13 HarfBuzz text foundation (no Graphite) | Long-term option only; out of scope for this change's gates | + +### Recommendation (proposed) + +- **Now through first slices (M0/M1):** Option 2. Keep every Gecko consumer as a classified + legacy boundary. The lexiconEdit-reachable surfaces — dictionary preview pane, configure-dictionary + dialog, MGA help pane, and the print path — remain legacy WinForms surfaces under the + coexistence model; the forbidden-symbol tests keep the migrated region clean. +- **Before the Avalonia default switch:** decide and validate Option 1 + Option 3 — WebView2 for + the (a)-classified surfaces (dictionary preview pane, DictionaryConfigurationDlg, MGA help) and + `PrintToPdfAsync` + OS-viewer printing replacing both `Gecko Window.Print()` and + `FieldWorksPdfMaker.exe` on the default path; (d)-classified surfaces (interlinear config, + grammar sketch, parser trace, import wizard, S/R instructions) may follow later or stay legacy + with their areas. +- **Prerequisite for any Gecko removal:** make the startup init (`FieldWorks.cs:164-192`) lazy or + optional; today a missing Firefox folder is a hard startup failure, so packaging cannot drop + XULRunner until that changes. + +--- + +## Decision gates + +| Gate | Decision needed by | Owner action | Blocked work if undecided | +|---|---|---|---| +| Browser/PDF strategy sign-off (accept/modify the Option 2 → Option 1+3 recommendation above) | **Before the Avalonia default switch — explicitly NOT before the first migrated slice.** First slices proceed under Option 2 (classified legacy boundary) with no decision required. | Owner (john_lambert@sil.org) approves or amends this proposal; record the decision in this file and flip 5.7 to done | Avalonia-default readiness (task 5.8 validation, 10.5 browser/PDF replacement validation, 7.5 default gate) | +| Graphite-warning coverage for any converted surface | With (not after) the first surface converted off Gecko | Confirm `graphite-transition-support` classification + G0-G3 warning machinery covers the surface | Converting any preview/print/PDF surface to WebView2 | +| Startup-init laziness | Before removing XULRunner from packaging | Schedule a task to make `FieldWorks.cs:164-192` lazy/optional | Dropping the Firefox folder, Geckofx NuGets, or `FieldWorksPdfMaker.exe` from the installer | + +Until the first gate is decided, the standing classification is: **all Gecko consumers are +legacy-boundary surfaces outside the migrated Avalonia region; the migrated region's freedom from +them is enforced by `EngineIsolationAuditTests`.** diff --git a/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md b/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md index fdcd9d6083..e84991aea4 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md +++ b/openspec/changes/lexical-edit-avalonia-migration/graphite-decommissioning.md @@ -1,5 +1,15 @@ # Graphite and Native Rendering Decommissioning Plan +> **Superseded for Graphite policy (2026-06-09).** Graphite support policy now lives in the +> dedicated change **`graphite-transition-support`**: Graphite remains supported on legacy surfaces +> until the M2 mid-decommissioning milestone, Avalonia surfaces render with OpenType plus a graded +> per-writing-system **warning** (G0–G3 classification) instead of being blocked, and removal +> happens at M3 with WinForms. The "decommissioning starts with the migration" and "Avalonia +> default blocked by Graphite" posture in this document no longer applies. What REMAINS in scope +> here: the native-engine isolation audit (no `GraphiteEngineClass`/native Views shaping on the +> Avalonia path — true under the new policy too), the inventory tables below as reference, and the +> Gecko/browser/PDF classification (sections still owned by lexical-edit tasks 5.5–5.8). + This plan treats Graphite and native Views rendering as a migration risk, not as a solved problem. The key correction from re-research: Avalonia using Skia/HarfBuzz does not automatically prove Graphite parity. HarfBuzz Graphite2 shaping is optional and its own documentation says Graphite2 support is currently not enabled by default when building HarfBuzz. ## 1. Current Repo Inventory diff --git a/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md b/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md new file mode 100644 index 0000000000..c047acacc6 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md @@ -0,0 +1,295 @@ +# XML Layout Import Coverage Report + +Generated by `LayoutImportCoverageTests` over the shipped layout/parts files under +`DistFiles/Language Explorer/Configuration/Parts/` (task 4.9). Regenerate by running that test; +the content is deterministic. Coverage percentages count *occurrences* in the shipped files, +so they weight the vocabulary by how often real layouts use it. + +## Summary + +- Detail layouts imported: **136** (non-detail layouts skipped: 594) +- Typed nodes produced: **1128** +- Element occurrence coverage: **75.5%** (6269 handled / 2031 unhandled) +- Attribute occurrence coverage: **55.5%** (14276 handled / 11435 unhandled) + +## Import diagnostics by code + +| Code (severity) | Count | +|---|---| +| `unhandled-attribute (Info)` | 715 | +| `slice-content-dropped (Info)` | 497 | +| `dynamic-editor (Info)` | 122 | +| `unresolved-part (Error)` | 63 | +| `unhandled-attribute (Warning)` | 5 | +| `unknown-part-content (Warning)` | 2 | +| `sublayout-dropped (Info)` | 1 | +| `unknown-editor (Warning)` | 1 | + +## Unresolved part refs (B10) + +| Class-Ref | Count | +|---|---| +| `RnGenericRec-DateCreated` | 12 | +| `RnGenericRec-DateModified` | 12 | +| `WfiAnalysis-HeavySummary` | 3 | +| `LexEntryType-Summary` | 2 | +| `MoEndoCompound-HeadLast` | 2 | +| `PartOfSpeech-Section` | 2 | +| `ReversalIndexEntry-Section` | 2 | +| `CmAnthroItem-Summary` | 1 | +| `CmPerson-Role` | 1 | +| `CmPossibility-Summary` | 1 | +| `CmSemanticDomain-Summary` | 1 | +| `FsClosedValue-Summary` | 1 | +| `FsComplexFeature-Message` | 1 | +| `FsComplexValue-Summary` | 1 | +| `FsFeatStruc-Blank` | 1 | +| `FsFeatStrucType-Message` | 1 | +| `FsFeatureSpecification-Summary` | 1 | +| `LexEntry-ImportResidue` | 1 | +| `LexEntryInflType-Summary` | 1 | +| `LexEtymology-NormalSummary` | 1 | +| `LexExtendedNote-NormalSummary` | 1 | +| `LexPronunciation-MediaFiles` | 1 | +| `LexReference-ShowSingleReference` | 1 | +| `LexSense-HeavySummary` | 1 | +| `LexSense-ImportResidue` | 1 | +| `LexSense-Pictures` | 1 | +| `MoAlloAdhocProhib-Message` | 1 | +| `MoExoCompound-ToMsa` | 1 | +| `MoInflAffixSlot-Optional` | 1 | +| `MoInflClass-SubclassesAllA` | 1 | +| `MoMorphAdhocProhib-Message` | 1 | +| `PhPhoneme-Codes` | 1 | +| `Text-DateCreated` | 1 | +| `Text-DateModified` | 1 | +| `WfiWordform-HeavySummary` | 1 | + +## Unhandled elements (census) + +| Element | Count | +|---|---| +| `string` | 295 | +| `lit` | 279 | +| `generate` | 161 | +| `properties` | 160 | +| `para` | 131 | +| `deParams` | 126 | +| `configureMlString` | 99 | +| `chooserLink` | 94 | +| `chooserInfo` | 93 | +| `forecolor` | 89 | +| `span` | 65 | +| `bold` | 52 | +| `obj` | 50 | +| `bordertrailing` | 34 | +| `cell` | 34 | +| `int` | 34 | +| `gendate` | 30 | +| `commandIcon` | 25 | +| `editable` | 25 | +| `seq` | 22 | +| `fontsize` | 17 | +| `sublayout` | 17 | +| `row` | 16 | +| `multiling` | 13 | +| `style` | 13 | +| `stringList` | 12 | +| `div` | 8 | +| `italic` | 5 | +| `leadingindent` | 5 | +| `borderbottom` | 4 | +| `datetime` | 4 | +| `firstindent` | 4 | +| `table` | 4 | +| `picture` | 3 | +| `RecordChangeHandler` | 2 | +| `alignment` | 1 | +| `computedString` | 1 | +| `innerpile` | 1 | +| `maxlines` | 1 | +| `savehvo` | 1 | +| `spaceafter` | 1 | + +## Unhandled attributes (census) + +| Element@Attribute | Count | +|---|---| +| `part@after` | 1469 | +| `part@before` | 1467 | +| `part@type` | 1066 | +| `part@ws` | 940 | +| `part@wsType` | 730 | +| `part@css` | 729 | +| `part@sep` | 618 | +| `string@field` | 295 | +| `part@showLabels` | 286 | +| `string@ws` | 207 | +| `part@style` | 179 | +| `generate@class` | 161 | +| `generate@fieldType` | 161 | +| `generate@restrictions` | 161 | +| `part@hideConfig` | 159 | +| `seq@inheritSeps` | 109 | +| `deParams@ws` | 102 | +| `configureMlString@field` | 99 | +| `chooserLink@label` | 94 | +| `chooserLink@tool` | 94 | +| `chooserLink@type` | 94 | +| `layout@tagForWs` | 87 | +| `forecolor@value` | 85 | +| `string@class` | 77 | +| `lit@translate` | 65 | +| `seq@class` | 65 | +| `if@class` | 64 | +| `part@flowType` | 62 | +| `seq@targetclass` | 54 | +| `slice@assemblyPath` | 54 | +| `slice@class` | 54 | +| `slice@helpTopicID` | 53 | +| `bold@value` | 52 | +| `obj@field` | 50 | +| `obj@layout` | 50 | +| `slice@editable` | 48 | +| `deParams@displayProperty` | 46 | +| `obj@class` | 44 | +| `chooserInfo@text` | 41 | +| `part@beforeStyle` | 40 | +| `slice@weight` | 37 | +| `slice@layout` | 36 | +| `seq@sep` | 35 | +| `bordertrailing@value` | 34 | +| `int@field` | 34 | +| `gendate@field` | 30 | +| `gendate@format` | 30 | +| `part@paramType` | 30 | +| `slice@header` | 28 | +| `slice@textStyle` | 27 | +| `editable@value` | 25 | +| `commandIcon@visibility` | 24 | +| `layout@css` | 24 | +| `seq@field` | 22 | +| `seq@layout` | 22 | +| `slice@tooltip` | 21 | +| `fontsize@value` | 17 | +| `sublayout@name` | 17 | +| `part@number` | 16 | +| `part@parastyle` | 16 | +| `seq@reverse` | 16 | +| `ifnot@stringaltequals` | 15 | +| `ifnot@ws` | 15 | +| `part@entrytype` | 15 | +| `obj@targetclass` | 14 | +| `part@numsingle` | 14 | +| `part@numstyle` | 14 | +| `multiling@ws` | 13 | +| `part@cssNumber` | 13 | +| `part@recurseConfig` | 13 | +| `string@labelws` | 13 | +| `style@value` | 13 | +| `part@lexreltype` | 12 | +| `part@singlegraminfofirst` | 12 | +| `stringList@group` | 12 | +| `stringList@ids` | 12 | +| `chooserInfo@guicontrol` | 11 | +| `layout@choiceGuid` | 11 | +| `multiling@sep` | 11 | +| `part@disallowCharStyle` | 11 | +| `slice@id` | 11 | +| `sublayout@group` | 10 | +| `layout@label` | 9 | +| `string@target` | 9 | +| `chooserInfo@flidTextParam` | 8 | +| `deParams@changeRequiresRefresh` | 8 | +| `if@func` | 8 | +| `ifnot@class` | 8 | +| `ifnot@func` | 8 | +| `slice@sameObject` | 8 | +| `slice@visField` | 8 | +| `part@preventnullstyle` | 7 | +| `slice@chooserDlgHelpTopicID` | 7 | +| `configureMlString@class` | 6 | +| `configureMlString@target` | 6 | +| `if@hvoequals` | 6 | +| `ifnot@hvoequals` | 6 | +| `obj@layoutArg` | 6 | +| `part@comment` | 6 | +| `part@field` | 6 | +| `seq@excludeHvo` | 6 | +| `slice@toggleValue` | 6 | +| `chooserInfo@textparam` | 5 | +| `if@index` | 5 | +| `ifnot@index` | 5 | +| `italic@value` | 5 | +| `leadingindent@value` | 5 | +| `part@showasindentedpara` | 5 | +| `seq@frag` | 5 | +| `slice@ghostClass` | 5 | +| `slice@ghostField` | 5 | +| `slice@reorder` | 5 | +| `sublayout@style` | 5 | +| `borderbottom@value` | 4 | +| `chooserInfo@title` | 4 | +| `datetime@field` | 4 | +| `firstindent@value` | 4 | +| `if@stringaltequals` | 4 | +| `if@ws` | 4 | +| `part@collapsedLayout` | 4 | +| `seq@indent` | 4 | +| `slice@spell` | 4 | +| `stringList@field` | 4 | +| `table@columns` | 4 | +| `seq@firstOnly` | 3 | +| `seq@ghostAbbe` | 3 | +| `slice@indent` | 3 | +| `slice@message` | 3 | +| `RecordChangeHandler@assemblyPath` | 2 | +| `RecordChangeHandler@class` | 2 | +| `RecordChangeHandler@listName` | 2 | +| `chooserInfo@helpBrowser` | 2 | +| `chooserLink@target` | 2 | +| `deParams@editable` | 2 | +| `part@indent` | 2 | +| `picture@height` | 2 | +| `seq@sort` | 2 | +| `slice@abrev` | 2 | +| `slice@backColor` | 2 | +| `slice@forceIncludeEnglish` | 2 | +| `where@class` | 2 | +| `where@stringaltequals` | 2 | +| `where@ws` | 2 | +| `alignment@value` | 1 | +| `computedString@argument` | 1 | +| `computedString@method` | 1 | +| `configureMlString@ws` | 1 | +| `datetime@format` | 1 | +| `deParams@menu` | 1 | +| `deParams@nullLabel` | 1 | +| `generate@destClass` | 1 | +| `maxlines@value` | 1 | +| `obj@inheritSeps` | 1 | +| `part@autoexp` | 1 | +| `part@blankPossible` | 1 | +| `part@commandVisible` | 1 | +| `part@forceSubentryDisplay` | 1 | +| `part@items` | 1 | +| `part@notifyVirtual` | 1 | +| `part@recurseConfigLabel` | 1 | +| `part@shouldNotMerge` | 1 | +| `part@sortType` | 1 | +| `seq@checkForEmptyItems` | 1 | +| `seq@numdelay` | 1 | +| `seq@targetClass` | 1 | +| `slice@forVariant` | 1 | +| `slice@labelws` | 1 | +| `slice@optionalWs` | 1 | +| `slice@refreshDataTreeOnChange` | 1 | +| `slice@sameObjectNext` | 1 | +| `slice@sideEffect` | 1 | +| `slice@sideEffectMethod` | 1 | +| `slice@skipSpacerLine` | 1 | +| `slice@thumbnail` | 1 | +| `slice@wsempty` | 1 | +| `spaceafter@value` | 1 | +| `string@menu` | 1 | +| `sublayout@layoutChoiceField` | 1 | diff --git a/openspec/changes/lexical-edit-avalonia-migration/localization-review.md b/openspec/changes/lexical-edit-avalonia-migration/localization-review.md new file mode 100644 index 0000000000..1d3218dfd1 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/localization-review.md @@ -0,0 +1,164 @@ +# Localization Review — Avalonia / UI-Mode Changes (Task 10.13) + +Date: 2026-06-09 + +Scope: every product-facing string introduced by this change — the FwAvalonia surfaces +(`Src/Common/FwAvalonia`, excluding `FwAvaloniaTests`), their xWorks consumption +(`RecordEditView`, `LexicalEditRegionEditContext`), and the UI-mode controls in +`LexOptionsDlg`. Reviewed against the `fieldworks-localization-review` skill checklist: +`.resx` coverage, Crowdin compatibility, stable automation IDs, localized user messages, and +explicit evidence for remaining prototype strings. + +--- + +## 1. `.resx` coverage — PASS + +- `Src/Common/FwAvalonia/FwAvaloniaStrings.resx` carries all 8 product-facing Avalonia strings + (placeholder `ksNoEntrySelected`, unsupported-record `ksEntryTypeUnsupported`, + unsupported-editor `ksUnsupportedEditor`, `ksSave`, `ksCancel`, `ksUndoEditEntry`, + `ksRedoEditEntry`, validation `ksLexemeFormRequired`), each with a translator comment. +- `Src/Common/FwAvalonia/FwAvaloniaStrings.cs` is the resx-backed accessor + (`ResourceManager("FwAvalonia.FwAvaloniaStrings", ...)`); the SDK-style csproj embeds the + resx by default globbing with manifest name `FwAvalonia.FwAvaloniaStrings.resources` + (default `RootNamespace` = project name `FwAvalonia`), which the accessor's base name + matches. +- All 8 keys have live consumers: `LexicalEditRegionView` (Save/Cancel/UnsupportedEditor), + `PocWinFormsHostControl.ShowNoEntry` (NoEntrySelected), `RecordEditView.cs:419` + (EntryTypeUnsupported), `LexicalEditRegionEditContext.cs:110/130` (LexemeFormRequired, + Undo/RedoEditEntry). +- `FwAvaloniaStringsTests` (`FwAvaloniaTests/RegionEditingTests.cs:217-230`) proves every key + resolves non-empty from resources. + +## 2. Crowdin compatibility — PARTIAL (one registration gap found) + +How Crowdin discovers resx in this repo (facts): + +- Root `crowdin.json` maps `"source": "Src/**/*.resx"` with ignores `Src/**/*Tests/**/*` and + `Src/**/HelpTopicPaths.resx`. `Src/Common/FwAvalonia/FwAvaloniaStrings.resx` matches the + glob and no ignore, so **upload to Crowdin needs no registration** — it is picked up + automatically, like every other project resx. The UI-mode keys live in the long-established + `Src/LexText/LexTextControls/LexTextControls.resx`, also covered. +- Translation download/build is `Build/Localize.targets` → `LocalizeFieldWorks` task → + `Localizer`/`ProjectLocalizer` (`Build/Src/FwBuildTasks/Localization/`). `Localizer` + collects every folder under `Src/` containing exactly one csproj, skipping folders ending + in `Tests` — so `FwAvalonia` is collected and `FwAvaloniaTests` correctly skipped. + +**GAP-L1 (build-side, must fix before claiming localized output):** +`ProjectLocalizer.GetResourceInfo` (`Build/Src/FwBuildTasks/Localization/ProjectLocalizer.cs:84-93`) +**requires an explicit `` element in the csproj** and logs an error +("Can't find RootNamespace...") and returns null when absent. `FwAvalonia.csproj` is SDK-style +and declares no `` (it relies on the SDK default). By static reading, the +localization build will therefore error/skip FwAvalonia and **no `FwAvalonia.resources.dll` +satellite assemblies will be produced** even though Crowdin returns translations. +Fix: add `FwAvalonia` to `Src/Common/FwAvalonia/FwAvalonia.csproj`. +Caveat/what to verify: this finding is from code reading, not an executed localization build — +confirm by running the `Localize.targets` lane (requires `CROWDIN_API_KEY`; runs in the +installer CD workflows, which install `overcrowdin`) or by unit-driving `ProjectLocalizer` +against the FwAvalonia folder, and also confirm whether the logged error fails the whole +localization build rather than just skipping the project. Installer packaging of the new +satellite dll is a follow-on check once it exists. + +## 3. Stable automation IDs — PASS + +- All automation selectors are nonlocalized code constants, never resource lookups: + `LexicalEditRegionView`, `RegionEditor.Save`, `RegionEditor.Cancel`, + `RegionEditor.ValidationErrors`, per-field ids from the IR (`field.AutomationId` falling + back to `StableId`, plus `.Label`/`.{wsAbbrev}` suffixes), `PocWinFormsHostControl`/ + `AvaloniaHost`/`RecordEditView.AvaloniaPoc` on the WinForms host. +- Locked by tests: `PocLexEntrySliceTests` (stable Avalonia automation metadata), + `WinFormsUiaSmokeTests` (nonlocalized filter-combo ids), and the region editing suites + (tasks 2.12, 6.8, 6.9). + +## 4. Localized user messages — PASS for messages, with label-lane gap (see GAP-L3) + +- Every user-facing *message* on the product Avalonia path is resx-backed (section 1): + placeholder, unsupported-record, unsupported-editor, validation error, undo/redo labels, + Save/Cancel. Validation errors surface through `IRegionEditContext.Validate()` → + `LexicalEditRegionView` in place; the unsupported-surface behavior under the global switch + (6.12) uses `ksEntryTypeUnsupported`. +- Non-migrated consumers fall back to the legacy surface (no new fallback message exists to + localize — recorded in task 2.12). + +## 5. UI-mode labels in LexOptionsDlg — PASS + +- `Src/LexText/LexTextControls/LexOptionsDlg.cs` reads all five UI-mode strings through + `GetOptionString(...)` → `LexTextControls.ResourceManager`; the keys exist in + `Src/LexText/LexTextControls/LexTextControls.resx:1316-1335` with translator comments: + `UiModeGroupTitle` ("Lexical Edit UI:"), `UiModeLabel` ("Mode:"), `UiModeLegacy` ("Legacy"), + `UiModeNew` ("New"), `UiModeRestartToApply` ("Restart to apply"). +- The English literals in `LexOptionsDlg.cs:451-482` are fallback defaults used only if the + resource is missing; the resx is authoritative, locked by + `LexOptionsDlgTests.UIModeControls_ReadDisplayTextFromResx` + (`Src/LexText/LexTextControls/LexTextControlsTests/LexOptionsDlgTests.cs`). +- Mode values persisted/broadcast (`"Legacy"`/`"New"`, `UIMode` property name) are + nonlocalized identifiers, correctly separate from the display text. + +## 6. Remaining hardcoded string literals in FwAvalonia production source — classified + +Census method: grep of multi-word string literals over `Src/Common/FwAvalonia` excluding +`FwAvaloniaTests`, plus file-by-file reading of the product render path. Every hit classified: + +### Resx-backed (correct) +- All consumers listed in section 1. + +### Preview-host-only (acceptable by contract; `Poc*` files are preview-only — task 4.8/7.9) +- `Poc/PocLexEntrySlice.cs:24,47,48` — "Lexical Edit POC Slice", "Lexeme Form", "Morph Type". +- `Poc/MorphTypePopupChooser.cs:35,56` — "Morph type options", "Morph Type" (reached only via + `PocLexEntrySlice`/`ShowEntry`, which is preview-host-only). +- `Poc/PocPreviewWindow.cs:22`, `Poc/PocEntryDto.cs` sample data, + `Poc/PocPreviewDataProvider.cs` ws/font literals, `Preview/AssemblyPreviewModules.cs:6` + ("Lexical Edit POC" module name). +- **Note:** `Poc/PocWinFormsHostControl.cs` is *not* preview-only despite the prefix — it is + the product WinForms host (`RecordEditView.cs:69,98,520`). Its strings are classified below. + +### Automation/accessibility identifiers (correctly nonlocalized) +- `LexicalEditRegionView` ids, `RegionEditor.*` ids, field ids (section 3). +- `PocWinFormsHostControl.cs:28,29,36` — `Name`/`AccessibleName` values + "PocWinFormsHostControl", "RecordEditView.AvaloniaPoc", "AvaloniaHost" (selector-shaped, + used by host contract tests). + +### Developer/diagnostic strings (nonlocalized by design, not user-surfaced) +- `XmlLayoutImporter.cs` / `LayoutImportCoverage.cs` import diagnostics (shown in coverage + reports and region diagnostics, not rendered to end users by `LexicalEditRegionView`). +- `LexicalEditSurfaceSelectionService.cs:69,84` `SurfaceDecision.Reason` strings (not rendered + by `RecordEditView`; logged/tested only). +- `MorphTypeSwapLogic.cs:73` decision reason; `SeamImplementations.cs:29` exception message. +- `LexicalEditFirstSlice.cs:122-124` `authored-fallback` diagnostic message. + +### GAP — needs migration before broader rollout +- **GAP-L2 (screen-reader-visible English on product controls):** + `Region/LexicalEditRegionView.cs:42` — `AutomationProperties.SetName(this, "Lexical Edit + Region")`; `Poc/PocWinFormsHostControl.cs:37` — `AccessibleName = "Avalonia Host"`. + `AutomationId` must stay nonlocalized, but UIA *Name* is announced by screen readers and + should be resx-backed (the Save/Cancel buttons already set localized Names — these two + container names are the stragglers). Low severity, two strings. +- **GAP-L3 (field-label localization lane not wired):** the region renders `field.Label` raw + (`LexicalEditRegionView.cs:178,185`). Labels originate in shipped layout XML (English) via + the importer, and `LexicalEditFirstSlice.cs:90` additionally stamps the literal + "Lexeme Form" (the `AuthoredFallback` at `:115-117` stamps "Lexeme Form"/"Morph Type"/ + "Gloss"). Legacy `DataTree` localizes these same layout labels at display time through + `XmlUtils.GetLocalizedAttributeValue`/`StringTable` (`DataTree.cs:3349-3350`), whose + `strings-{locale}.xml` lane is Crowdin entry #1 in `crowdin.json`. The Avalonia path has no + equivalent pass, so in a non-English UI the new view shows English field labels where the + legacy view shows translated ones. The IR already carries `LocalizationKey` (task 4.7) and + the mapper propagates it (`LexicalEditRegionMapper.cs:66-67`) — what is missing is resolving + labels through the StringTable lane (or populating `LocalizationKey`) at compile or render + time. This is the substantive localization-parity gap; it should be fixed (or explicitly + waived with owner sign-off) before any region claims localization parity under 10.8. + +## Verdict per checklist item + +| Checklist item | Verdict | +|---|---| +| `.resx` coverage of product-facing Avalonia strings | **PASS** (8/8 keys, all consumed, test-locked) | +| Crowdin compatibility | **PARTIAL** — upload auto-discovered via `crowdin.json` glob; **GAP-L1**: missing `` in `FwAvalonia.csproj` likely breaks satellite-assembly build; verify via the Localize lane | +| Stable automation IDs (nonlocalized) | **PASS** (code constants, test-locked) | +| Localized user messages (placeholder/unsupported/validation/undo-redo) | **PASS** | +| UI-mode labels in `LexOptionsDlg` use resx | **PASS** (`LexTextControls.resx`, test-locked; in-code literals are fallbacks only) | +| Explicit evidence for remaining prototype strings | **DONE** — census above; preview-only strings confined to `Poc*` preview paths; **GAP-L2** (2 UIA names) and **GAP-L3** (field-label localization lane) are the open items | + +Open actions: (1) add `FwAvalonia` and verify the localization +build produces `FwAvalonia.resources.dll` (GAP-L1); (2) move the two container UIA names to +`FwAvaloniaStrings.resx` (GAP-L2); (3) decide and implement the field-label localization lane +— StringTable pass or `LocalizationKey` population — before localization parity is claimed for +the region (GAP-L3). diff --git a/openspec/changes/lexical-edit-avalonia-migration/native-views-audit.md b/openspec/changes/lexical-edit-avalonia-migration/native-views-audit.md new file mode 100644 index 0000000000..59ce351d5a --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/native-views-audit.md @@ -0,0 +1,218 @@ +# Native Views Audit (Tasks 8.1, 8.2, 8.6, 8.7, 8.8) + +Date: 2026-06-09 + +Scope: the migrated Lexical Edit region is the FwAvalonia region-model path +(`Src/Common/FwAvalonia` + `RecordEditView` routing in `Src/xWorks`). The legacy region it replaces +is `DataTree`/`Slice` (`Src/Common/Controls/DetailControls`), which is substantially +RootSite/native-Views-backed. Every row cites a verified file path in this worktree. Rows are +marked **[E]** evidence-based (verified declaration/call site) or **[J]** judgment call (reachability +or phase assignment inferred, not proven by a runtime trace). + +Related enforcement already in place: `Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs` +(tasks 5.5/5.8) forbids the FwAvalonia production assembly from referencing +`ViewsInterfaces`/`RootSite`/`SimpleRootSite`/Graphite/Gecko assemblies and from naming +`IVwRootBox`, `IVwEnv`, `IVwGraphics`, `RootSiteControl`, `ManagedVwWindow`, etc. in source. + +--- + +## 8.1 Inventory + +Native Views/C++ viewing/rendering/editor dependencies reachable from the legacy Lexical Edit +region (`RecordEditView` → `DataTree` → slices), bottom-up. + +### 8.1.1 Engine and interop foundation + +| Dependency | File evidence | What it provides | Mark | +|---|---|---|---| +| Native Views engine (C++) | `Src/views/VwRootBox.cpp`, `Src/views/VwEnv.cpp`, `Src/views/VwSelection.cpp`, `Src/views/VwSimpleBoxes.cpp`, `Src/views/VwTableBox.cpp`, `Src/views/VwLazyBox.cpp` | Box layout, paragraph layout, selection model, laziness for all RootSite views | [E] | +| Native IME/TSF integration | `Src/views/VwTextStore.cpp` / `VwTextStore.h` | Text Services Framework store: composition, keyboard input into the root box | [E] | +| Native accessibility | `Src/views/VwAccessRoot.cpp` | IAccessible exposure of view boxes | [E] | +| Native render engines | `Src/views/lib/UniscribeEngine.cpp`, `Src/views/lib/UniscribeSegment.cpp`, `Src/views/lib/GraphiteEngine.cpp`, `Src/views/lib/GraphiteSegment.cpp` | Text shaping/segmenting (Uniscribe + Graphite) | [E] | +| Native TsString/props kernel | `Src/views/lib/TsString.cpp`, `Src/views/lib/TsTextProps.cpp`, `Src/views/lib/TextServ.cpp` | Native string/props used by the C++ engine (managed code uses LCModel's managed TsString) | [E] | +| Managed COM interop layer | `Src/Common/ViewsInterfaces/Views.cs` (`IVwRootBox`, `IVwEnv`, `IVwSelection`, `IVwGraphics`, `IVwDrawRootBuffered`, `SetSpellingRepository` at lines 7524/8489/9432) | The managed face of the native engine; everything below consumes it | [E] | +| `ManagedVwWindow` | **Retired.** `Src/ManagedVwWindow` removed by the archived `retire-linux-era-view-shims` change (its `tasks.md` 3.2/3.3 done); only installer-removal records remain (`Build/Installer.legacy.targets:116-119`, CLSID exclusion `Build/mkall.targets:46`) | Linux-era shim; **not** a live dependency of Lexical Edit | [E] | + +### 8.1.2 Managed hosting/editor stack + +| Dependency | File evidence | What it provides | Mark | +|---|---|---|---| +| `SimpleRootSite` | `Src/Common/SimpleRootSite/SimpleRootSite.cs:39` (`UserControl, IVwRootSite, IRootSite, ...`) | WinForms host of an `IVwRootBox`: paint, scroll, focus, keyboard dispatch | [E] | +| Buffered rendering path | `Src/Common/SimpleRootSite/SimpleRootSite.cs:230` (`IVwDrawRootBuffered m_vdrb`), `:5338` (`VwDrawRootBufferedClass.Create()`) | All on-screen drawing of view content | [E] | +| Editor realization / typing | `Src/Common/SimpleRootSite/EditingHelper.cs:1054-1082` (`CallOnTyping` → `Callbacks.EditedRootBox.OnTyping(...)`) | Keystrokes become document edits inside the native engine | [E] | +| Hit testing / selection | `Src/Common/SimpleRootSite/EditingHelper.cs:1492` (`rootb.MakeSelAt(...)`), `Src/Common/SimpleRootSite/SelectionHelper.cs`, `Src/Common/SimpleRootSite/TextSelInfo.cs` | Mouse→selection, selection persistence/restore metadata | [E] | +| Render-engine selection | `Src/Common/SimpleRootSite/RenderEngineFactory.cs:110-120` (`GraphiteEngineClass.Create()` when `ws.IsGraphiteEnabled`, else Uniscribe) | Per-WS shaping engine choice for every RootSite view | [E] | +| Managed view constructors | `Src/Common/SimpleRootSite/VwBaseVc.cs` (`FwBaseVc` lives beside it) | Base class for the `IVwEnv`-driven display logic each slice view implements | [E] | +| `RootSite` / `RootSiteControl` | `Src/Common/RootSite/RootSite.cs:61`, `Src/Common/RootSite/RootSiteControl.cs:15` | LCModel-aware root site; base of nearly every slice view below | [E] | +| Spell-check squiggle wiring | `Src/Common/RootSite/RootSite.cs:800` (`m_rootb.SetSpellingRepository(SpellingHelper.GetCheckerInstance)`), `Src/Common/RootSite/SpellCheckHelper.cs`, `Src/Common/RootSite/RootSiteEditingHelper.cs:349` | Native engine draws squiggles by querying the managed spell engine | [E] | +| Clipboard rich-text bridge | `Src/Common/SimpleRootSite/TsStringWrapper.cs` | Serialized TsString clipboard format (now also the 3.13 shared seam) | [E] | +| Printing path | `Src/Common/SimpleRootSite/PrintRootSite.cs` | Print layout via the native engine | [E] | + +### 8.1.3 Views-backed slice classes in the legacy Lexical Edit region + +`Slice` itself is a plain WinForms `UserControl` (`Src/Common/Controls/DetailControls/Slice.cs:46`); +the Views dependency enters through these subclasses and their inner view controls. + +| Slice / view class | File evidence | Views usage | Mark | +|---|---|---|---| +| `ViewSlice` (base) | `Src/Common/Controls/DetailControls/ViewSlice.cs:16` | Hosts a `RootSite` control as the slice body | [E] | +| `ViewPropertySlice : ViewSlice` | `Src/Common/Controls/DetailControls/ViewPropertySlice.cs:11` | Base of the property-bound Views slices | [E] | +| `MultiStringSlice : ViewPropertySlice` | `Src/Common/Controls/DetailControls/MultiStringSlice.cs:29,33` (creates `LabeledMultiStringView`) | Multi-WS string editing — the most common Lexical Edit editor (`SliceFactory.cs:85-107` "first, these are the most common slices") | [E] | +| `LabeledMultiStringView` (adapter) | `Src/Common/Controls/Widgets/LabeledMultiStringView.cs:29` (`UserControl, IxCoreColleague`) | WS-labeled wrapper around the inner root site | [E] | +| `InnerLabeledMultiStringView` | `Src/Common/Controls/Widgets/InnerLabeledMultiStringView.cs:26` (`: RootSiteControl`) | The actual native-Views text editor for multistring slices | [E] | +| `InnerLabeledMultiStringControl` | `Src/Common/Controls/Widgets/InnerLabeledMultiStringControl.cs:17` (`: SimpleRootSite`) | Cache-light variant used in dialogs (e.g. insert-entry) | [E] | +| `StringSlice : ViewPropertySlice` | `Src/Common/Controls/DetailControls/StringSlice.cs:24`; inner `StringSliceView : RootSiteControl` at `:388` | Single-WS string editing (`SliceFactory.cs:146-154`) | [E] | +| `StTextSlice : ViewPropertySlice` | `Src/Common/Controls/DetailControls/StTextSlice.cs:29`; inner `StTextView : RootSiteControl` at `:228` | Structured-text editing (`SliceFactory.cs:305`) | [E] | +| `GhostStringSlice : ViewPropertySlice` | `Src/Common/Controls/DetailControls/GhostStringSlice.cs:35,64,77` (`GhostStringSliceVc : FwBaseVc`, `GhostStringSliceView : RootSiteControl`); created in `DataTree.cs:2822` | Ghost (not-yet-created property) editing and become-real editor realization | [E] | +| `SummarySlice : ViewSlice` | `Src/Common/Controls/DetailControls/SummarySlice.cs:28`; `LiteralLabelView : RootSiteControl` at `:669`; `SummaryXmlView : XmlView` at `:800` | Section header rendering uses the Views engine even for labels | [E] | +| `PhoneEnvReferenceSlice : ReferenceSlice` | `Src/Common/Controls/DetailControls/PhoneEnvReferenceSlice.cs:26`; `PhoneEnvReferenceView : RootSiteControl` (`PhoneEnvReferenceView.cs:33`); created in `SliceFactory.cs:301` | Phonological-environment editing with live validation | [E] | +| `ReferenceViewBase : RootSiteControl` | `Src/Common/Controls/DetailControls/ReferenceViewBase.cs:26` | Base of all reference-launcher views | [E] | +| Reference views (atomic/vector) | `Src/Common/Controls/DetailControls/AtomicReferenceView.cs` (5 `IVwRootBox/IVwEnv` hits), `PossibilityAtomicReferenceView.cs`, `VectorReferenceView.cs`, `PossibilityVectorReferenceView.cs` | Display + selection inside `AtomicReferenceSlice`/`ReferenceVectorSlice`/possibility slices (`ReferenceSlice.cs:25`, `AtomicReferenceSlice.cs:19`, `ReferenceVectorSlice.cs:30`) | [E] | +| `AtomicRefTypeAheadSlice` | `Src/Common/Controls/DetailControls/AtomicRefTypeAheadSlice.cs:54` (`AtomicRefTypeAheadView : RootSiteControl`) | Type-ahead reference editing | [E] | +| `AudioVisualSlice : ViewSlice` | `Src/Common/Controls/DetailControls/AudioVisualSlice.cs:33,388` (`AudioVisualView : RootSiteControl`) | Pronunciation media file display | [E] | +| `MediaInfoSlice : ViewSlice` | `Src/Common/Controls/DetailControls/MediaInfoSlice.cs:20,89` (`MediaInfoView : RootSiteControl`) | Media info display | [E] | +| `MultiLevelConc` / `TwoLevelConc` slices | `Src/Common/Controls/DetailControls/MultiLevelConc.cs:291,391`, `TwoLevelConc.cs:343,376` (`ConcView : RootSiteControl`, `ConcSlice : ViewSlice`) | Legacy concordance slices in DetailControls | [E] | +| `GhostReferenceVectorSlice : FieldSlice` | `Src/Common/Controls/DetailControls/GhostReferenceVectorSlice.cs:23` | Ghost variant for vector refs; realizes a Views-backed reference slice on edit | [E] | + +### 8.1.4 Custom-editor slices outside DetailControls reachable from Lexical Edit layouts + +Loaded via `SliceFactory`'s `custom`/assembly-loaded editor path from lexicon layout XML. + +| Class | File evidence | Mark | +|---|---|---| +| `ReversalIndexEntrySliceView : RootSiteControl` | `Src/LexText/Lexicon/ReversalIndexEntrySlice.cs:216` | [E] | +| `MSADlglauncherView : RootSiteControl, IVwNotifyChange` | `Src/LexText/Lexicon/MSADlglauncherView.cs:14` | [E] | +| `MsaInflectionFeatureListDlgLauncherView : RootSiteControl` | `Src/LexText/Lexicon/MsaInflectionFeatureListDlgLauncherView.cs:20` | [E] | +| `PhonologicalFeatureListDlgLauncherView : RootSiteControl` | `Src/LexText/Lexicon/PhonologicalFeatureListDlgLauncherView.cs:13` | [E] | +| `StringRepSliceView : RootSiteControl` (env. string rep) | `Src/LexText/Morphology/PhEnvStrRepresentationSlice.cs:236` | [E] | +| `AnalysisInterlinearRs : RootSite` (Words/Analyses slice) | `Src/LexText/Morphology/AnalysisInterlinearRS.cs:27` | [E] — reachability from *lexiconEdit* layouts vs the Words area is [J] (it serves the Analyses detail view) | + +### 8.1.5 Views-backed widgets used by region dialogs/launchers + +| Widget | File evidence | Mark | +|---|---|---| +| `FwTextBox` / `InnerFwTextBox : SimpleRootSite` | `Src/Common/Controls/Widgets/FwTextBox.cs:1658` | [E] | +| `FwListBox` / `InnerFwListBox : SimpleRootSite` | `Src/Common/Controls/Widgets/FwListBox.cs:1252` | [E] | +| `FwMultiParaTextBox` / `InternalFwMultiParaTextBox : SimpleRootSite` | `Src/Common/Controls/Widgets/FwMultiParaTextBox.cs:220` | [E] | + +--- + +## 8.2 Classification + +Categories: **Baseline/fallback-only** (kept for parity comparison and the explicit legacy UI mode), +**Non-migrated-region-only** (other tools/areas), **Blocker** (the Avalonia region must replace it — +i.e. what gate 6.13's TsString text foundation plus 6.x editors must cover). + +Important framing [E]: under the global UI-mode contract (tasks 1.9/3.10), the *entire* legacy +DataTree stack remains shipping as the selectable legacy surface during coexistence. "Blocker" +therefore means "the Avalonia region cannot claim parity until a managed replacement exists", not +"delete now". Deletion is gated by 8.5/8.6 and the legacy-mode sunset. + +| Dependency (from 8.1) | Classification | Rationale | Mark | +|---|---|---|---| +| `MultiStringSlice` + `LabeledMultiStringView`/`InnerLabeledMultiStringView` | **Blocker** | The single most common Lexical Edit interaction; exactly what 6.13's multi-WS TsString foundation must replace (read/write, per-WS fonts/keyboards, IME, bidi) | [E] | +| `StringSlice` (`StringSliceView`) | **Blocker** | Single-WS text editing; covered by 6.13 + 6.1/6.2 | [E] | +| `GhostStringSlice` | **Blocker** | Ghost→real editor realization must be reproduced managed (IR ghost metadata + edit session) | [E] | +| `StTextSlice` (`StTextView`) | **Blocker** | Multi-paragraph text editing in entries (e.g. comments); needs the 6.13 foundation plus paragraph support | [E]; priority within lexicon parity is [J] | +| `SummarySlice` (`LiteralLabelView`, `SummaryXmlView`) | **Blocker** | Even header labels render through Views today; Avalonia region renders headers natively (already does in `LexicalEditRegionView`) | [E] | +| `PhoneEnvReferenceSlice`/`PhoneEnvReferenceView` | **Blocker** | Environment editing is part of lexeme-form/allomorph parity | [E]; phase placement (7.4) is [J] | +| `ReferenceViewBase` family (atomic/vector/possibility views + their slices) | **Blocker** | Reference display/selection inside launchers; replaced by Avalonia chooser/launcher controls (6.3) | [E] | +| `AtomicRefTypeAheadSlice` | **Blocker** | Type-ahead reference editing in the region | [E]; usage frequency is [J] | +| Lexicon custom slices (`ReversalIndexEntrySlice`, `MSADlglauncherView`, `MsaInflectionFeatureListDlgLauncherView`, `PhonologicalFeatureListDlgLauncherView`, `StringRepSliceView`) | **Blocker** | Reachable from shipped lexicon detail layouts via the custom-editor path | [E] for class/Views backing; per-layout reachability is [J] (custom editors are layout-driven) | +| `AudioVisualSlice` / `MediaInfoSlice` | **Blocker** (late-phase) | Pronunciation media slices appear in LexEntry layouts | [J] — reachable from Pronunciations; can be deferred behind explicit fallback under 6.12 | +| `MultiLevelConc` / `TwoLevelConc` | **Non-migrated-region-only** | Legacy concordance slices; not part of lexicon edit layouts | [J] — no lexiconEdit layout reference found; treat as dead/other-area code | +| `AnalysisInterlinearRs` | **Non-migrated-region-only** | Serves the Words/Analyses detail view (`Analyses` is an explicit legacy fallback in `RecordEditViewSwitchTests`) | [E] for the fallback routing; [J] for "never reachable from lexiconEdit" | +| `FwTextBox`/`FwListBox`/`FwMultiParaTextBox` widgets | **Blocker where used inside region-owned dialogs/choosers; otherwise shell-phase** | Region chooser/dialog replacements (6.3) must not re-host SimpleRootSite text boxes; app-wide dialog usage is the shell change's problem | [J] split; widget Views backing is [E] | +| `SimpleRootSite`/`RootSite`/`RootSiteControl`, `EditingHelper`, `SelectionHelper`, `RenderEngineFactory`, `IVwDrawRootBuffered` path | **Blocker (as the implied foundation) + Baseline/fallback-only (as shipping code)** | The Avalonia region must own rendering/selection/hit-testing/typing/IME itself (6.13, 8.3); the classes themselves stay for the legacy surface and parity baselines | [E] | +| `ViewsInterfaces` interop + native `Src/views` engine | **Baseline/fallback-only for this region; deletion blocked repo-wide (8.6)** | The migrated region must never load them (enforced by `EngineIsolationAuditTests`); other areas still need them | [E] | +| Spell squiggle wiring (`SetSpellingRepository`) | **Blocker (behavior), service (engine)** | Squiggle drawing/suggestion UI must be re-owned by Avalonia; the spell engine itself is a retained service (8.7/8.8) | [E] | +| `TsStringWrapper` clipboard format | **Retained shared seam** (not a blocker) | Deliberately adopted as the cross-surface clipboard contract (task 3.13) | [E] | +| `PrintRootSite` / printing | **Non-migrated-region-only / later phase** | Region printing flows are not in the current parity scope | [J] | +| `ManagedVwWindow` | **Already retired** | See 8.1.1 | [E] | + +--- + +## 8.6 Repo-wide native Views deletion blockers outside Lexical Edit + +Even after Lexical Edit migrates, these consumers keep `Src/views`, `ViewsInterfaces`, +`SimpleRootSite`, and `RootSite` alive. Phase column: `lexical-edit 7.x` (this change), +`shell phase` (`fieldworks-avalonia-shell-migration`), or `unplanned` (no openspec change exists; +the roadmap defers it — `openspec/changes/avalonia-migration-roadmap/proposal.md` lines 25-32). +Phase assignments are [J] unless a change document names them. + +| Consumer area | What it uses | File evidence | Migration phase | +|---|---|---|---| +| Interlinear text (Texts & Words) | `RootSite`-derived document/analysis views: `InterlinDocRootSiteBase`, `InterlinDocForAnalysis`, `InterlinTaggingChild`, `InterlinPrintChild`, `RawTextPane`, `TitleContentsPane`, `SandboxBase`/`Sandbox` | `Src/LexText/Interlinear/InterlinDocRootSiteBase.cs:28`, `InterlinDocForAnalysis.cs:30`, `InterlinTaggingChild.cs:29`, `InterlinPrintView.cs:17`, `RawTextPane.cs:31`, `TitleContentsPane.cs:22`, `SandboxBase.cs:34`, `Sandbox.cs:21` | unplanned | +| Concordance | Concordance container/control hosting browse (XMLViews) result views and interlinear panes | `Src/LexText/Interlinear/ConcordanceControl.cs:34`, `ConcordanceContainer.cs:23` | unplanned | +| Discourse chart | `ConstChartBody : RootSite`, `InterlinRibbon : InterlinDocRootSiteBase` | `Src/LexText/Discourse/ConstChartBody.cs:22`, `InterlinRibbon.cs:24` | unplanned | +| XMLViews browse/table (all areas) | `XmlBrowseViewBase : RootSite` + `XmlBrowseView`, `XmlBrowseRDEView`, `OneColumnXmlBrowseView`, `XmlView : RootSiteControl`, `XmlSeqView : RootSite` | `Src/Common/Controls/XMLViews/XmlBrowseViewBase.cs:28`, `XmlBrowseView.cs:26`, `XmlBrowseRDEView.cs:31`, `BrowseViewer.cs:1917`, `XmlView.cs:93`, `XmlSeqView.cs:113` | lexical-edit 7.1/7.2 for the lexicon browse pane; other areas' browse views: unplanned | +| xWorks document views | `XmlDocItemView : XmlView` in `RecordDocView` | `Src/xWorks/RecordDocView.cs:236` | unplanned | +| Notebook | `RecordEditView` + `DataTree` slices (notebook detail uses the same DetailControls stack); routed as explicit legacy fallback (`notebookEdit`) under the UI mode | `Src/xWorks/RecordEditView.cs`, fallback coverage in `Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs` (task 2.11) | unplanned (explicit legacy fallback meanwhile) | +| Grammar / Lists detail | Same DetailControls slice stack via `RecordEditView` (`posEdit`, `domainTypeEdit` fallbacks) | `Src/xWorks/xWorksTests/RecordEditViewSwitchTests.cs` (task 2.11) | unplanned (explicit legacy fallback meanwhile) | +| Grammar morphology tools | `InflAffixTemplateControl : XmlView`, `PatternView : RootSiteControl`, `OneAnalysisSandbox : SandboxBase` | `Src/LexText/Morphology/InflAffixTemplateControl.cs:30`, `Src/LexText/LexTextControls/PatternView.cs:18`, `Src/LexText/Morphology/OneAnalysisSandbox.cs:16` | unplanned | +| Parser UI | `TryAWordRootSite : RootSiteControl`, `TryAWordSandbox : SandboxBase` | `Src/LexText/ParserUI/TryAWordRootSite.cs:24`, `TryAWordSandbox.cs:17` | unplanned | +| FdoUi dialogs | `RelatedWordsView : SimpleRootSite` | `Src/FdoUi/Dialogs/RelatedWords.cs:553` | unplanned | +| Core dialogs / find-replace | `SampleView : SimpleRootSite` (converter tester), `BulletsPreview : SimpleRootSite`, `FwTextBox` in `FwFindReplaceDlg`, `ValidCharactersDlg` | `Src/FwCoreDlgs/ConverterTester.cs:529`, `Src/FwCoreDlgs/FwCoreDlgControls/FwBulletsPreview.cs:30`, `Src/FwCoreDlgs/FwFindReplaceDlg.cs`, `Src/FwCoreDlgs/ValidCharactersDlg.cs` | shell phase | +| Shared Views widgets (app-wide) | `FwTextBox`, `FwListBox`, `FwMultiParaTextBox`, `InnerLabeledMultiStringControl` | `Src/Common/Controls/Widgets/FwTextBox.cs:1658`, `FwListBox.cs:1252`, `FwMultiParaTextBox.cs:220`, `InnerLabeledMultiStringControl.cs:17` | shell phase | +| Framework hosting/printing | `FwRootSite : RootSite`, `PrintRootSite` | `Src/Common/Framework/FwRootSite.cs:26`, `Src/Common/SimpleRootSite/PrintRootSite.cs` | shell phase | +| Filters (spell-check matcher) | `BadSpellingMatcher` consumes `ISpellEngine` but lives in the Views-adjacent filter stack used by browse views | `Src/Common/Filters/BadSpellingMatcher.cs:41` | follows XMLViews browse migration | +| Reg-free COM packaging of the native Views/Kernel COM servers | Build tooling keeps native CLSIDs activatable for everything above | `Build/RegFree.targets`, `Build/mkall.targets:46`, `Src/Common/FieldWorks/BuildInclude.targets`, `Build/Src/FwBuildTasks/RegHelper.cs` | last to go; shrinks as consumers retire | + +Bottom line [E+J]: native Views deletion is blocked by (at minimum) Interlinear/Texts & Words, +Discourse, XMLViews browse in every area, xWorks doc views, the DetailControls stack for +Notebook/Grammar/Lists, app-wide Views widgets, and Framework printing. This change only removes +the *Lexical Edit region's* dependence (8.3-8.5); repo-wide deletion needs the shell change plus +currently-unplanned area migrations. + +--- + +## 8.7 Non-viewing native dependencies (custom linguistics / service / tool) + +Classification rule applied: a dependency is a retained service/tool unless it owns display, +layout, hit testing, selection, or editor realization. For each row the "owns no viewing behavior" +claim was checked by locating its UI touchpoints. + +| Dependency | Kind | File evidence | Owns display/layout/hit-test/selection/editor realization? | +|---|---|---|---| +| XAmple morphological parser (native `xample.dll`) | Custom linguistics engine | `Src/LexText/ParserCore/XAmpleManagedWrapper/XAmpleDLLWrapper.cs:37+` (`DllImport("xample.dll")`), `Src/LexText/ParserCore/XAmpleParser.cs:22`, `Src/LexText/ParserCore/XAmpleCOMWrapper/XAmpleCOMWrapper.vcxproj` | **No** [E] — returns parse/trace XML (`ParseResult.cs`); display happens in ParserUI (`XAmpleTrace.cs`, `WebPageInteractor.cs`), not in the engine | +| HermitCrab parser | Managed linguistics engine | `Src/LexText/ParserCore/HCParser.cs:22`, `HCLoader.cs` | **No** [E] — fully managed, same `IParser` contract | +| pcpatr / ToneParsFLEx / HCSynthByGloss utilities | External tool wrappers | `Src/Utilities/pcpatrflex/DisambiguateInFLExDB/PCPatrInvoker.cs`, `Src/Utilities/pcpatrflex/ToneParsFLExDll/XAmpleDLLWrapperForTonePars.cs`, `Src/LexText/ParserCore/PatrParserWrapper/`, `Src/Utilities/HCSynthByGloss/HCSynthByGlossLib/Synthesizer.cs` | **No** [E] — they own their *own* WinForms tool UI (`PcPatrFLExForm.cs`, `ToneParsFLExForm.cs`), which is standalone-tool UI, not Views render/editor infrastructure [J on the standalone-tool framing] | +| Encoding converters (ECInterfaces / SilEncConverters40) | Conversion service | `Src/FwCoreDlgs/AddCnvtrDlg.cs:12,16` (`using ECInterfaces; using SilEncConverters40;`), `Src/FwCoreDlgs/CnvtrPropertiesCtrl.cs`, importer consumers `Src/LexText/LexTextControls/LexImportWizard.cs`, `Src/LexText/Interlinear/LinguaLinksImport.cs` | **No** [E] — string-conversion API; the FwCoreDlgs converter dialogs are WinForms hosts around it (the `ConverterTester.SampleView : SimpleRootSite` preview is a *RootSite* dependency, counted in 8.6, not an EncConverters one) | +| ICU (icu.net managed + native data) | Unicode/collation service | `Src/ManagedLgIcuCollator/LgIcuCollator.cs:7-8` (`using Icu; using Icu.Collation;`), `CustomIcu` usage `Src/xWorks/CssGenerator.cs:242`, `Src/FXT/FxtDll/XDumper.cs:164`; native side consumed inside C++ views/lib (`Src/views/lib/LgUnicodeCollater.cpp`) | **No** [E] — normalization/collation/character properties. (Text *shaping* is Uniscribe/Graphite and is classified as viewing in 8.1, not here) | +| Spell-check engine (Hunspell via managed `SpellingHelper`) | Service | `Src/Common/RootSite/SpellCheckHelper.cs:15` (`using SIL.LCModel.Core.SpellChecking;`), `:257` (`SpellingHelper.GetSpellChecker`), `Src/Common/Filters/BadSpellingMatcher.cs:41` | **No for the engine** [E]. The *interop into native Views* (`VwRootBox::SetSpellingRepository`, `Src/views/VwRootBox.cpp:291`, `Src/Kernel/FwKernel.idh:81-87`) exists solely so the native renderer can draw squiggles — that drawing is a viewing behavior owned by Views (8.1/8.2), not by the spell engine | +| Expat / ParserObject (native XML parsing) | Native build-time/legacy utility | `Lib/src/xmlparse/xmlparse.h`, `Lib/src/ParserObject/CExPat.cpp`; referenced by native Views test/build (`Src/views/Test/TestViews.vcxproj`, `Src/views/Views.mak`) | **No** [E] — XML parsing only; retires with the native C++ tree, not with any UI work | +| Reg-free COM tooling | Build/packaging service | `Build/RegFree.targets`, `Build/mkall.targets:46`, `Build/Src/FwBuildTasks/RegHelper.cs`, `Src/Common/FieldWorks/BuildInclude.targets` | **No** [E] — manifests/activation for native COM servers (FwKernel, Views); it serves viewing code but owns none | + +Confirmation: none of the above owns display, layout, hit testing, selection, or editor +realization. The only one that *touches* rendering is spell-check, and there the rendering half +(squiggles, suggestion menus) belongs to RootSite/Views (`SpellCheckHelper` builds the menu, +`VwRootBox` draws), so the engine remains a clean service. This confirmation is evidence-based for +the call paths cited; "no other hidden UI ownership" is a [J] negative claim backed by the searches +in this audit, not an exhaustive proof. + +--- + +## 8.8 Service seams for retained linguistics engines + +Rule (applies to every row): **the Avalonia region consumes results through these managed +contracts and never hosts, links, or re-implements the engines' UI/render/editor infrastructure.** +The `EngineIsolationAuditTests` assembly/symbol audit is the enforcement backstop; engines below +must stay out of `FwAvalonia`'s reference graph entirely — consumption happens in service layers +(xWorks/LexText) behind seam interfaces. + +| Engine | Managed service seam (is / should be) | File evidence | Avalonia consumption rule | +|---|---|---|---| +| XAmple + HermitCrab parsers | `IParser` (engine contract) under `ParserScheduler`, consumed by UI through `ParserConnection` | `Src/LexText/ParserCore/IParser.cs`, `Src/LexText/ParserCore/ParserScheduler.cs:51`, `Src/LexText/ParserUI/ParserConnection.cs:21` | Avalonia surfaces request parses/trace results via `ParserConnection` (or a thin async port over it) and render results with Avalonia controls; never P/Invoke `xample.dll`, never embed the legacy trace HTML host (`WebPageInteractor.cs`) | +| Spell checking | `SpellingHelper` + `ISpellEngine` (managed Hunspell, `SIL.LCModel.Core.SpellChecking`); `IGetSpellChecker` is the *legacy native* seam only | `Src/Common/RootSite/SpellCheckHelper.cs:257`, `Src/Common/RootSite/RootSiteEditingHelper.cs:349`, native-only seam `Src/Common/ViewsInterfaces/Views.cs:7524` | Avalonia text editors query `ISpellEngine` directly for check/suggest/add and draw their own squiggles/menus; they MUST NOT call `SetSpellingRepository` or any `IGetSpellChecker` COM path (that exists only for `VwRootBox`) | +| Encoding converters | `ECInterfaces` (`IEncConverter`/`IEncConverters`) implemented by `SilEncConverters40` | `Src/FwCoreDlgs/AddCnvtrDlg.cs:12,16`, import consumers `Src/LexText/LexTextControls/LexImportWizard.cs` | Avalonia import/export flows call `ECInterfaces` conversions; converter *configuration* UI remains the external EncConverters/WinForms surface until separately rebuilt — Avalonia never embeds it | +| ICU | icu.net managed wrappers (`Icu.*`) and `CustomIcu` (SIL.LCModel.Core) | `Src/ManagedLgIcuCollator/LgIcuCollator.cs:7-8`, `Src/xWorks/CssGenerator.cs:242` | Avalonia uses `Icu`/`CustomIcu` for normalization/collation/character data only; text shaping/layout comes from Avalonia's own HarfBuzz/Skia stack, never from `Src/views/lib` engines | +| pcpatr / ToneParsFLEx / HCSynthByGloss | Process/DLL invoker utilities (`PCPatrInvoker`, `ToneParsInvoker`, `Synthesizer`) in standalone tool projects | `Src/Utilities/pcpatrflex/DisambiguateInFLExDB/PCPatrInvoker.cs`, `Src/Utilities/HCSynthByGloss/HCSynthByGlossLib/Synthesizer.cs` | Out of scope for the migrated region: these are standalone tools with their own UI; FwAvalonia takes no reference to them [J — should-be statement; no current seam needed] | +| Native TsString kernel (for completeness) | Managed TsString (`SIL.LCModel.Core.Text`/`KernelInterfaces`) + the `TsStringWrapper` clipboard contract | `Src/Common/SimpleRootSite/TsStringWrapper.cs`, seam bridge `FwTsStringClipboard` (task 3.13, xWorks) | Avalonia reads/writes TsStrings only through LCModel-managed APIs and the 3.13 clipboard seam; the native `Src/views/lib/TsString.cpp` kernel is never touched from FwAvalonia | + +Seam status notes: +- `ParserConnection`, `ISpellEngine`, `ECInterfaces`, and `Icu`/`CustomIcu` are **existing** seams + in product code today [E]. No new abstraction is required before Avalonia consumes them; what is + required is keeping the consumption *outside* `FwAvalonia` assembly references (LCModel-free rule, + enforced by `EngineIsolationAuditTests.ProductionAssembly_ReferencesNoNativeRenderLegacyOrDomainAssemblies`) + and routing through the region's service interfaces in `Src/Common/FwAvalonia/Seams/ISeams.cs`. +- "Should-be" rows (pcpatr family) are judgment calls [J]; everything else is evidence-based. diff --git a/openspec/changes/lexical-edit-avalonia-migration/proposal.md b/openspec/changes/lexical-edit-avalonia-migration/proposal.md index 1ba4bfea8c..c004f6ca76 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/proposal.md +++ b/openspec/changes/lexical-edit-avalonia-migration/proposal.md @@ -11,7 +11,7 @@ Current branch scope is judged by the branch-only diff against `main`, not by sa - Introduce a typed, managed view-definition/Presentation IR as the migration boundary. Existing XML Parts/Layout remains an import source during transition; long-term runtime XML dependency is retired only after parity is proven. - Treat lexical-edit UI mode as an app-wide product switch while keeping host behavior explicit per consumer: every current `RecordEditView` consumer must declare whether Avalonia mode is supported, falls back to legacy, or is blocked with a deliberate product-facing diagnostic. - Make native viewing/rendering decommissioning a completion gate for each migrated region: if native code owns display, layout, measurement, hit testing, selection, or editor realization, it SHALL NOT be brought into the completed Avalonia region. Custom linguistics engines and native services such as XAmple, spelling, parser/conversion tools, or similar language-documentation capability may remain when isolated behind service seams outside the Avalonia render/editor path. -- Start Graphite/native-rendering decommissioning with the migration. Avalonia SHALL NOT become the default Lexical Edit screen until Graphite font settings, native Graphite engines, Gecko Graphite rendering, PDF/export assumptions, tests, docs, and build/package artifacts are inventoried, classified, and either replaced, retained behind a legacy boundary, or blocked with explicit diagnostics and rollback. +- Graphite policy (revised 2026-06-09, owned by the `graphite-transition-support` change): Graphite remains supported on legacy surfaces until the M2 mid-decommissioning milestone and sunsets with tooling, while Avalonia surfaces render Graphite-affected writing systems with OpenType shaping plus a graded per-writing-system warning — never a hard block. This change retains only the engine-isolation audit (no native Graphite/Views shaping on the Avalonia path) and the Gecko/browser/PDF classification. - Require dependency-injected services around DataTree/Slice/Launcher behavior, view-definition source/import/compile/cache, editor selection, edit sessions, LCModel transactions, undo/redo grouping, validation, command/focus routing, UI dispatch, lifetime/disposal, diagnostics, and render/parity capture. - Freeze seam-specific recommendations in dedicated capability specs for edit sessions, undo/redo, validation, command/focus, UI scheduling, and lifetime so phase-one lexical work and phase-two shell work consume the same decisions. - Define migrated-region manifests and hard gates so each claimed Avalonia region has explicit entry points, allowed legacy adapters, forbidden native/Graphite call paths, custom linguistics service dependencies, parity fixtures, performance budgets, and rollback/default-switch rules. @@ -35,7 +35,7 @@ Current branch scope is judged by the branch-only diff against `main`, not by sa - `lexical-edit-avalonia-migration`: End-to-end phased migration requirements for Lexical Edit from WinForms/DataTree/XMLViews toward Avalonia. - `lexical-edit-view-definition`: Typed view-definition and Presentation IR requirements, including XML import during transition, dynamic editor diagnostics, stable identity, virtualization/focus metadata, and XML retirement gates. - `lexical-edit-parity-automation`: Test, UI automation, render verification, and semantic parity requirements for WinForms and Avalonia migration safety. -- `lexical-edit-font-decommissioning`: Graphite/native rendering classification, OpenType/HarfBuzz font-option migration where supported, Gecko/browser/PDF impact, and native dependency requirements. +- `lexical-edit-font-decommissioning`: engine-isolation audit for the Avalonia path, OpenType/HarfBuzz font-option migration where supported, Gecko/browser/PDF impact, and native dependency requirements. (Graphite *support policy* — legacy-surface support, Avalonia warnings, sunset milestones — moved to the dedicated `graphite-transition-support` change, 2026-06-09.) - `avalonia-edit-sessions`: FieldWorks-owned edit-session and commit-boundary requirements for editable Avalonia regions, starting from the current direct LCModel fenced-session model. - `avalonia-undo-redo`: Domain-authoritative undo/redo requirements with control-local leaf undo allowed only as a subordinate behavior. - `avalonia-validation`: FieldWorks-owned validation seam requirements with Avalonia-native presentation and package-backed rule engines as subordinate options. diff --git a/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md index c0168ee566..8f55256f0c 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md +++ b/openspec/changes/lexical-edit-avalonia-migration/region-manifest.md @@ -112,18 +112,74 @@ Exceptions must be documented in the manifest with owner, reason, tests, and rol | Rendering gate | Writing-system/font/Graphite capability matrix is classified and default path blocks unsupported cases with rollback. | | Performance gate | Provisional budgets are measured against named fixtures and hardware before becoming enablement criteria. | -## 5. Provisional Performance Budgets +## 5. Performance Budgets -Budgets are placeholders until measured. They must not be used as pass/fail claims until each has fixture ID, machine profile, command, and artifact path. +### 5.1 Measured legacy DataTree baselines (task 2.13, 2026-06-09) -| Metric | Provisional Target | Notes | +Measured via the existing characterization harness (`DataTreeRenderTests`/`DataTreeRenderHarness`), which times real `DataTree` initialization (mediator, inventories, form) and `ShowObject` slice population per fixture. + +- **Machine profile:** 12th Gen Intel Core i7-12700, 64 GB RAM, Windows 11 Pro, 96 DPI (100%), Debug build. +- **Command:** `dotnet test Src/Common/Controls/DetailControls/DetailControlsTests/DetailControlsTests.csproj -c Debug --filter "FullyQualifiedName~DataTreeRenderTests"` with `FW_REPORT_TIMING_BASELINES=1`. +- **Artifacts:** raw per-run timings in `Output/RenderBenchmarks/datatree-timings.json`; enforced local thresholds (measured + ~50% headroom) committed as `Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeTimingBaselines.json`, checked by `DataTreeTimingBaselineCatalog` on every render-test run. + +Representative measured numbers (Init / Populate / Total ms): + +| Fixture (scenario id) | Slices | Init | Populate | Total | +|---|---|---|---|---| +| `simple` (LexEntry, 3 senses) | 14 | 162 | 47 | 358 | +| `multiws` (multi-WS alternatives) | 12 | 138 | 72 | 343 | +| `subsubsub-hidden-productionlike` (depth-4 production-like lexeme edit) | 68 | 77 | 26 | 864 | +| `timing-extreme` (depth-6, large fixture) | 253 | 66 | 93 | 2483 | + +### 5.2 Avalonia budgets derived from the baselines + +The Avalonia surface for an equivalent fixture must come in **within 20% of the legacy Total** above (or the delta is explicitly accepted in the manifest). Cold vs warm runs measured separately. + +| Metric | Target | Notes | |---|---|---| -| First region load | Within 20 percent of legacy baseline or explicitly accepted | Measure cold and warm separately. | -| Layout compile | Deterministic and cacheable; target under 250 ms for selected first-slice fixture | Use immutable config snapshots. | +| First region load | Within 20% of the measured legacy Total for the matching fixture | E.g. production-like depth-4 ≤ ~1040 ms on the profile above. | +| Layout compile | Deterministic and cacheable; under 250 ms for the first-slice fixture | Use immutable config snapshots (`ViewDefinitionCompiler` caches by fingerprint). | | Save/cancel command latency | No user-visible freeze for first editable slice | Measure UI-thread work and background work separately. | | Validation pass | Linear in materialized node count | Lazy/unmaterialized sequences must be skipped or explicitly loaded. | -## 6. Phasing +### 5.3 Refresh-after-edit (measured 2026-06-09) + +`DataTreeReshowTimingTests` measures the live-tree refresh path (`DataTree.RefreshList(false)` — the +slice-reuse rebuild legacy refresh drives; a same-root `ShowObject` early-outs at `DataTree.cs:1073`): +**5.6 ms for a 5-slice entry** after a citation-form edit (same machine profile as §5.1; artifact +key `timing-reshow-after-edit` in `datatree-timings.json`). Avalonia budget: region re-resolve + +re-show after an external edit within 20% of the matching fixture's legacy refresh. + +### 5.4 Still unmeasured (open) + +Scroll/expand latency and typing latency need dedicated harness scenarios; 150% DPI numbers need a +non-headless run on a scaled display. These remain open under task 2.13 and must be measured before +the corresponding gates become pass/fail claims. + +## 6. Gate Evaluation — Lexical Edit region (2026-06-09, tasks 7.4/7.5) + +The full-replacement gate is **established and enforced** (the UI mode defaults to Legacy until it +passes) and was evaluated against the composed full-entry view: + +| Gate | Status | Evidence | +|---|---|---| +| Switch contract | **Pass** | `LexicalEditSurfaceSelectionService` + `RecordEditViewSwitchTests` (every consumer declared, both modes) | +| Symbol audit | **Pass** | `EngineIsolationAuditTests` + `RecordEditViewActiveHostContractTests` | +| Layout gate (semantic) | **Partial** | Typed snapshots deterministic; full-tree semantic comparison vs legacy DataTree output for the composed view still to run | +| Edit gate | **Pass** (current scope) | Fenced session save/cancel/one-global-undo-step/refresh interaction (`LexicalEditRegionEditingTests`, `FullEntryRegionComposerTests`) | +| Validation gate | **Partial** | Required-field rule + deterministic localized messages; severity/async lanes pend richer rules | +| Accessibility gate | **Partial** | Stable ids everywhere; UIA names/order parity proven on the realized surface (`PreviewHostUiaTests`); keyboard-traversal assistive smoke pends chooser-dialog work | +| Rendering gate | **Pass** (policy) | Per `graphite-transition-support`: classification+warning coverage replaces the block; native-engine audit green | +| Performance gate | **Partial** | Open + refresh-after-edit measured with enforced thresholds; scroll/typing/memory lanes open (§5.4) | +| **Verdict** | **Default stays Legacy.** | Core parity items (entry identity, citation, morph type, senses/glosses/definitions structure, ifdata hiding, editing) are composed and proven; the gate blocks default until the Partial rows close — exactly what 7.5 requires. | + +P0 parity status (7.4): lexeme form ✔ editable multi-WS · citation form ✔ · morph type ✔ chooser · +senses structure ✔ headers/indent per sense · gloss ✔ per-sense editable · definition ✔ (ifdata) · +bibliography/restrictions/etc ✔ via metadata walk · variants/complex-form sections ✔ structure +(reference rows read-only) · custom fields ✘ (9.x B1) · ghost lines ✘ (B2) · rich TsString runs ✘ +(6.13 gate). + +## 7. Phasing | Phase | Manifest Work | |---|---| diff --git a/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md b/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md index b26284d882..2f3677e587 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md +++ b/openspec/changes/lexical-edit-avalonia-migration/seam-domain-comparison.md @@ -14,7 +14,9 @@ It complements `seam-recommendations.md` and `architecture-diagrams.md`. - **Avalonia 11.x only** until WinForms is gone (no Avalonia 12 message filter / dispatcher work; cross-boundary tab/focus and popup-DPI are ours to work around; host coarsely). - **~1-year coexist phase, then WinForms deleted.** Each UI *class* is wholly one framework, but - different classes run **concurrently** and cooperate via **selection** and **copy/paste**. + different classes run **concurrently** and cooperate via **selection**, **copy/paste**, and + **drag-and-drop** (product decision 2026-06-09), with **refresh propagation**, **one undo stack**, + and **dialog ownership** as first-editable-slice gates. - **XML-layout retirement is a separate effort** — keep XML→IR import; the typed IR is the runtime contract the Avalonia side consumes. @@ -22,8 +24,9 @@ It complements `seam-recommendations.md` and `architecture-diagrams.md`. Throwaway = wiring the new ports into **legacy internals** (e.g. threading `RefreshCoordinator` into the live `DataTree`, or `LexicalEditorRegistry` into `SliceFactory`), because that code is deleted at -cutover. **Not** throwaway: the cross-framework **selection** and **copy/paste** bridges — they are -real, must-build, and bidirectional, and the selection concept outlives WinForms. +cutover. **Not** throwaway: the cross-framework **selection**, **copy/paste**, and **drag-and-drop** +bridges — they are real, must-build, and bidirectional, and the selection/interchange concepts +outlive WinForms. ## A. Routing & view model @@ -46,10 +49,12 @@ real, must-build, and bidirectional, and the selection concept outlives WinForms | Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | |---|---|---|---|---|---| -| Selection sync ("current lexeme") | `IRecordNavigationContext` + `IPropertyStateStore` | WinForms views follow xCore `RecordClerk`/PropertyTable "current record" broadcast | Same bus; Avalonia is first-class publisher+subscriber | `IRecordNavigationContext` contract-only; `IPropertyStateStore` in-memory only | **Build it (not throwaway).** Bidirectional: the active surface *follows* the broadcast and *publishes* its own selection back. The bus already exists | -| Copy / paste | clipboard seam (not built) | Native Views clipboard (rich/structured TsString) | WS-aware framework-neutral clipboard | Unaddressed | **Build a shared FieldWorks clipboard format** (serialized multi-WS/TsString) both native-Views and Avalonia read/write, plus plain-text fallback; both hit the OS clipboard. Decide target fidelity early — rich Views formats won't round-trip natively | +| Selection sync ("current lexeme") | `IRecordNavigationContext` + `IPropertyStateStore` | WinForms views follow xCore `RecordClerk`/PropertyTable "current record" broadcast | Same bus; Avalonia is first-class publisher+subscriber | **Built (3.12, 2026-06-09):** `RecordClerkNavigationContext` in xWorks — publish via the clerk's real `OnJumpToRecord`/`OnNextRecord`/`OnPreviousRecord`, follow via the sponsoring `RecordEditView`'s real `OnRecordNavigation`; proven on the real mediator path (`RecordClerkNavigationContextTests`) | Bidirectional bridge done for the first host; extend to additional hosts as they gain Avalonia surfaces | +| Copy / paste | `IFwClipboard` seam | Native Views clipboard (rich/structured TsString) | WS-aware framework-neutral clipboard | **Built (3.13, 2026-06-09):** the shared format is the existing legacy `"TsString"` OS format (`TsStringWrapper` XML rep) + NFC `UnicodeText` fallback; `IFwClipboard`/`FwClipboardText` (LCModel-free, FwAvalonia) + `FwTsStringClipboard` (xWorks) round-trip with real `EditingHelper` writes/reads (`FwTsStringClipboardTests`) | Fidelity decided: multi-WS/styles round-trip; ORC object references and external consumers don't (documented in `IFwClipboard`). Wire into Avalonia editors at 6.1/6.2 | +| Drag & drop | DnD bridge (3.14, not built) | WinForms `DoDragDrop`, surface-internal only: `SliceTreeNode` (slice drag/reorder), `RecordBarTreeHandler` (record-bar tree moves) | Framework-neutral payloads over OS DnD | Unaddressed | **Build it — product decision (2026-06-09): cross-surface DnD IS supported.** Reuse the 3.13 clipboard payloads (legacy `"TsString"` format + record-key hvo/guid) over the OS DnD pipeline; in-surface reorder semantics stay surface-local; WinForms→Avalonia→WinForms round-trip test required (3.14) | | Focus / keyboard / tab | host edge | WinForms tab order across slices | Pure Avalonia focus within one host | Coarse hosting via `WinFormsAvaloniaControlHost` | Host coarsely — one big Avalonia view per host. Own focus *inside* the Avalonia view; don't fight cross-boundary Tab (open 11.x bug) | | Command routing (menus/xCore) | `IXCoreCommandBridge` | xCore mediator routes to active target | Avalonia commands + thin xCore bridge at shell phase | Contract-only | Bridge only the commands this screen needs this year; defer the general bridge to the shell migration | +| Dialog ownership / modality | host edge (3.16, not built) | WinForms dialogs owned by WinForms windows | Avalonia dialogs/flyouts own their own tree | Unaddressed | WinForms choosers/message boxes launched from an active Avalonia surface need correct owner, modality, z-order, and focus return through `WinFormsAvaloniaControlHost`; Avalonia popups inside WinForms hosts hit the 11.x popup-DPI quirk. Chooser-launch + focus-return smoke test (3.16); document unsupported combinations | ## D. Text & rendering @@ -63,7 +68,7 @@ real, must-build, and bidirectional, and the selection concept outlives WinForms | Domain | Seam | Before | Ideal | Now | Recommended (coexist year) | |---|---|---|---|---|---| -| Refresh coordination | `ILexicalRefreshCoordinator` | `DataTree` `DoNotRefresh`/`RefreshPending` flags inline | One coordinator both surfaces honor | `RefreshCoordinator` models the gate, tested; **not** wired into live `DataTree` | Wire on the Avalonia side; leave legacy inline flags alone (throwaway) | +| Refresh coordination | `ILexicalRefreshCoordinator` | `DataTree` `DoNotRefresh`/`RefreshPending` flags inline | One coordinator both surfaces honor | `RefreshCoordinator` models the gate, tested; **not** wired into live `DataTree` | Wire on the Avalonia side; leave legacy inline flags alone (throwaway). **Coexistence gate (3.15):** Avalonia commits must raise `PropChanged` legacy repaints from, and legacy edits + F5/`RefreshAllViews` must reach active Avalonia hosts — shared-cache consistency stands or falls on this loop; gates the first editable slice with a two-surface test | | Lifetime / disposal | `IRegionLifetime` | Slices dispose ad hoc | Explicit region ownership tree | Implemented + tested | Use for the Avalonia host/region | | UI scheduling / threading | `IUiScheduler` | WinForms `Control.Invoke` | Single dispatcher, marshalled | `ImmediateUiScheduler` (tests) + dispatcher at edge | Keep thin; single UI thread on 11.x | | Host/surface contract | `ILexicalEditHost`/`ILexicalEditSurface` | Implicit in `RecordEditView` | Explicit init/focus/context-menu/replacement contract | Contracts defined (3.5); `RecordEditView` conforms via the selection service | Formalize the active-host contract (3.10) as an audited invariant | diff --git a/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md index db7525344f..48b9db2f78 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md +++ b/openspec/changes/lexical-edit-avalonia-migration/seam-recommendations.md @@ -26,10 +26,17 @@ These are fixed product constraints; the seam choices below assume them: small islands sharing a WinForms tab order). 2. **~1-year coexist phase, then WinForms is deleted.** Each *class* of UI is wholly WinForms **or** wholly Avalonia at a time, but different classes run **concurrently** and must cooperate through - two shared channels: **selection** ("this is the current lexeme") and **copy/paste**. Those two - bridges are real, must-build, and bidirectional — not throwaway scaffolding. What *is* throwaway - is wiring the new ports into *legacy internals* (e.g. threading `RefreshCoordinator` into the live - `DataTree`), because that code is deleted at cutover. + three shared data channels: **selection** ("this is the current lexeme", task 3.12), **copy/paste** + (task 3.13), and **drag-and-drop** (task 3.14 — product decision 2026-06-09: cross-surface DnD is + supported, reusing the clipboard payload formats). These bridges are real, must-build, and + bidirectional — not throwaway scaffolding. Beyond data interchange, four behaviors gate the first + *editable* slice because both surfaces share one LCModel cache and one window chrome: + **cross-surface refresh propagation** (`PropChanged`/F5 reach both surfaces, task 3.15), **one + global undo/redo stack** (LCModel `IActionHandler`, 6.8/6.10 — two stacks is user-visible data + weirdness), **screen-local command/menu/focus routing** to the active surface (the local phase of + `avalonia-command-focus`), and **dialog ownership/modality** across the interop boundary (task + 3.16). What *is* throwaway is wiring the new ports into *legacy internals* (e.g. threading + `RefreshCoordinator` into the live `DataTree`), because that code is deleted at cutover. 3. **XML-layout retirement is a separate effort.** Moving authoring off XML Parts/Layout to a modern typed format is desirable but out of scope here; this change keeps the XML→IR importer and treats the typed IR as the runtime contract the Avalonia side consumes. @@ -46,8 +53,12 @@ switch until cutover. Concretely: it); it is now an audited invariant. - Replace the **lossy `LexicalEditPocMapper` DTO** on the product route with a **typed-definition-backed region model** (4.8); keep `PocEntryDto` for the preview host only. -- Build the **selection and clipboard bridges** as bidirectional adapters over the shared xCore/LCModel - substrate; do not re-plumb legacy internals. +- Build the **selection, clipboard, and drag-and-drop bridges** (3.12/3.13/3.14) as bidirectional + adapters over the shared xCore/LCModel/OS substrate; do not re-plumb legacy internals. Clipboard + and DnD speak the legacy `"TsString"` OS format so native-Views surfaces interoperate unchanged. +- Treat **cross-surface refresh propagation** (3.15) and **global LCModel undo/redo** as first-editable- + slice gates, not cleanup: shared-cache consistency stands or falls on the notification loop, and a + split undo history is the most jarring coexistence failure a user can hit. - Reproduce the prototype's **LCModel-fenced edit session and validation** behind `IEditSession`/the validation seam when the first product editor lands (6.x); the prototype branch is the reference. diff --git a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md index 9d833b46df..f90926ecf0 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md +++ b/openspec/changes/lexical-edit-avalonia-migration/specs/lexical-edit-font-decommissioning/spec.md @@ -1,16 +1,26 @@ ## ADDED Requirements -### Requirement: Graphite decommissioning starts with the migration - -The Lexical Edit Avalonia migration SHALL begin Graphite decommissioning immediately, even when Avalonia work is hidden behind preview hosts, feature flags, or non-default entry points. Avalonia SHALL never support Graphite. - -#### Scenario: Migration start creates Graphite retirement work -- **WHEN** implementation begins for Lexical Edit Avalonia migration -- **THEN** the task plan SHALL include inventory, migration, and validation tasks for retiring Graphite from the default path - -#### Scenario: Default screen is blocked by Graphite dependency -- **WHEN** Avalonia is proposed as the default Lexical Edit screen -- **THEN** Graphite dependencies SHALL be fully retired from the default path or explicitly classified as unsupported legacy dependencies outside that path +> **Superseded (2026-06-09):** the former first requirement here ("Graphite decommissioning starts +> with the migration" / "Avalonia SHALL never support Graphite" / "Default screen is blocked by +> Graphite dependency") is replaced by the `graphite-transition-support` change: Graphite remains +> supported on legacy surfaces until the M2 sunset milestone, and Avalonia surfaces raise a graded +> warning instead of blocking. The requirement below preserves only the part that survives — the +> native-engine isolation of the Avalonia path. + +### Requirement: The Avalonia path never loads the native Graphite engine + +Avalonia surfaces SHALL NOT load or call `GraphiteEngineClass`, `FwGrEngine`, `GraphiteSegment`, or +native Views render-engine selection. Graphite *support* during the transition (legacy-surface +rendering, Avalonia warnings, sunset milestones) is governed by `graphite-transition-support`; this +requirement governs only engine isolation, which holds under every transition path. + +#### Scenario: Engine isolation is audited per region +- **WHEN** a region manifest audit runs over migrated Avalonia production code +- **THEN** it SHALL find no reference to native Graphite engine or native Views shaping symbols + +#### Scenario: Graphite presence does not block an Avalonia default +- **WHEN** Avalonia is proposed as a default surface while Graphite-enabled writing systems exist +- **THEN** the decision SHALL be governed by `graphite-transition-support` warning/classification coverage, not by Graphite retirement ### Requirement: Graphite code, settings, and assets are inventoried diff --git a/openspec/changes/lexical-edit-avalonia-migration/tasks.md b/openspec/changes/lexical-edit-avalonia-migration/tasks.md index ace7e92a00..6adde5f9e0 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/tasks.md +++ b/openspec/changes/lexical-edit-avalonia-migration/tasks.md @@ -12,7 +12,7 @@ - [x] 1.8 Audit migration docs and evidence notes for stale branch/scope assumptions after each wiring or build-strategy change; compare against `main..HEAD`, not calendar-day commit lists, and keep proposal/design/coverage/evidence wording aligned with the live branch scope. - [x] 1.9 Define the global lexical-edit UI mode contract: the switch is app-wide, legacy UI remains selectable, and every current `RecordEditView` consumer has an explicit expected behavior (`Avalonia`, explicit legacy fallback, or blocked state) under both modes. - [x] 1.10 Define the build/test integration contract for Avalonia: Avalonia projects and tests participate in normal `./build.ps1` and `./test.ps1` coverage; runtime legacy-vs-Avalonia selection is a product behavior switch, not a separate build lane. -- [ ] 1.11 Execute the 1.10 contract: add `FwAvalonia` and `FwAvaloniaTests` to `FieldWorks.proj` (traversal) and `FieldWorks.sln` so `./build.ps1`/`./test.ps1` actually build and run them, and remove the "intentionally NOT added" isolation note from `FwAvalonia.csproj`. The spike handoff (`lexical-edit-avalonia-poc-spike/spike-evidence.md`) gated this on live-embedding evidence, which now exists (`RecordEditView` product wiring, preview-host UIA smoke, `RecordEditViewActiveHostContractTests`). Until this lands, design decision 13 is unmet and the primary evidence path is a branch-only lane. +- [x] 1.11 Execute the 1.10 contract: add `FwAvalonia` and `FwAvaloniaTests` to `FieldWorks.proj` (traversal) and `FieldWorks.sln` so `./build.ps1`/`./test.ps1` actually build and run them, and remove the "intentionally NOT added" isolation note from `FwAvalonia.csproj`. The spike handoff (`lexical-edit-avalonia-poc-spike/spike-evidence.md`) gated this on live-embedding evidence, which now exists (`RecordEditView` product wiring, preview-host UIA smoke, `RecordEditViewActiveHostContractTests`). Until this lands, design decision 13 is unmet and the primary evidence path is a branch-only lane. (Done 2026-06-09: the `FieldWorks.proj` traversal already includes all four projects via the `Src\**\*.csproj` glob — verified with `-getItem:ProjectReference`, tests gated on `BuildTests=true`; `FwAvalonia`, `FwAvaloniaTests`, `FwAvaloniaPreviewHost`, `FwAvaloniaPreviewHostTests` added to `FieldWorks.sln`; stale isolation comments replaced in both csproj files; output already flows to shared `Output\$(Configuration)` where `test.ps1` discovers `*Tests.dll`. Verified: build clean, 85/85 FwAvaloniaTests pass.) - [x] 1.12 Refresh `lexical-edit-avalonia-poc-spike/spike-evidence.md`: close Pending #1 (in-process embedding) with the product-wiring evidence that now exists; keep DPI density measurement and the rendered-frame native/Graphite assertion (Pending #2/#3) explicitly open with owners and target tasks. (Done 2026-06-09: Pending #1 marked closed with `RecordEditView`/`RecordEditViewActiveHostContractTests` evidence; #2 still open, #3 pointed at 6.9/8.4; handoff note now flags build-graph integration as due via 1.11.) - [x] 1.13 Reconcile the roadmap with the boundary actually built: `avalonia-migration-roadmap` defines Gate 1 around `datatree-model-view-separation` (`DataTreeModel`/`SliceSpec`/`IDataTreeView`), but execution went directly to this change's region-model boundary (`ViewDefinitionModel`/`LexicalEditRegionModel` through `RecordEditView`), and `seam-domain-comparison.md` now classifies wiring new ports into legacy `DataTree` internals as throwaway. Either formally supersede/re-scope `datatree-model-view-separation` and redefine Gate 1 around the region-model boundary, or document how the two boundaries converge. The repo must not carry two competing boundary vocabularies. (Done 2026-06-09: `datatree-model-view-separation` formally superseded as a migration gate — `DataTree` is frozen legacy, `DataTreeModel`/`SliceSpec`/`IDataTreeView` should not be built, optional partial-class/characterization work is maintenance only. Gate 1 redefined around the region-model boundary in `avalonia-migration-roadmap/design.md` with as-built vocabulary diagram. Both the roadmap `proposal.md`/`design.md` and `datatree-model-view-separation/hybrid-alignment.md`/`proposal.md` carry superseded banners.) - [x] 1.14 Standardize the owning-project name: replace remaining `AdvancedEntry.Avalonia` references in `region-manifest.md` (manifest-shape table and example JSON) and the `proposal.md` Impact section with `FwAvalonia`; keep a single glossary note mapping the prototype branch's `AdvancedEntry.Avalonia` to its reference-only role. (Done 2026-06-09: `region-manifest.md` table/example and branch note, `proposal.md` Impact updated; prototype branch named as reference-only.) @@ -31,7 +31,7 @@ - [x] 2.10 Add executable wiring baselines for the global UI mode: setting/app-setting source, `PropertyTable` broadcast, live host refresh, and current-content reload behavior without manual `OnPropertyChanged(...)` test calls. (`LexOptionsDlgTests` cover settings-to-`PropertyTable` mirroring; `RecordEditViewSwitchTests.LexiconEditTool_SwitchesSurfaceStateToAvalonia_WhenUIModePropertyBroadcasts` covers the real broadcast path and live content-control update without a manual handler call.) - [x] 2.11 Add coverage for every current `RecordEditView` consumer under both UI modes, including Lexicon, Grammar, Notebook, Lists, and Words paths, and assert the configured fallback or blocked behavior for non-migrated surfaces. (`RecordEditViewSwitchTests` now covers `lexiconEdit` as the supported Avalonia path plus representative non-migrated fallbacks for Grammar (`posEdit`), Notebook (`notebookEdit`), Lists (`domainTypeEdit`), and Words (`Analyses`).) - [x] 2.12 Add localization coverage for product-facing UI mode labels/messages and Avalonia fallback text, while keeping automation selectors stable through nonlocalized IDs rather than localized labels. (`LexOptionsDlgTests.UIModeControls_ReadDisplayTextFromResx` locks UI-mode resources; `WinFormsUiaSmokeTests` rely on stable nonlocalized filter-combo IDs; `PocLexEntrySliceTests` lock stable Avalonia automation metadata for first-slice controls. Current non-migrated product fallback is the explicit legacy path, so no separate localized fallback message is surfaced there yet.) -- [ ] 2.13 Measure and record legacy Lexical Edit performance baselines while the characterization harness is warm: entry open, refresh-after-edit, scroll/expand latency, and typing latency on large fixtures at 100% and 150% DPI, with fixture ID, machine profile, command, and artifact path. Replace the provisional budgets in `region-manifest.md` §5 with these measured numbers so 7.7 enforces real targets instead of placeholders. +- [ ] 2.13 Measure and record legacy Lexical Edit performance baselines while the characterization harness is warm: entry open, refresh-after-edit, scroll/expand latency, and typing latency on large fixtures at 100% and 150% DPI, with fixture ID, machine profile, command, and artifact path. Replace the provisional budgets in `region-manifest.md` §5 with these measured numbers so 7.7 enforces real targets instead of placeholders. (Partially done 2026-06-09: **entry-open baselines measured at 100% DPI** across 12 fixtures (5–253 slices) via `DataTreeRenderTests`/`DataTreeRenderHarness` — e.g. production-like depth-4: 864 ms total / 68 slices; depth-6 extreme: 2483 ms / 253 slices on i7-12700/64GB/Win11/Debug. Raw artifact `Output/RenderBenchmarks/datatree-timings.json`; enforced thresholds (+~50% headroom) committed as `DetailControlsTests/DataTreeTimingBaselines.json`, loaded and checked by `DataTreeTimingBaselineCatalog` on every render-test run (28/28 green, no threshold warnings); `region-manifest.md` §5 rewritten with the measured table and the 20%-of-legacy Avalonia budget rule. **Update 2026-06-09 (second pass):** refresh-after-edit now measured — `DataTreeReshowTimingTests` times the live-tree `RefreshList(false)` slice-reuse rebuild (a same-root `ShowObject` early-outs at `DataTree.cs:1073`): 5.6 ms / 5 slices, recorded as `timing-reshow-after-edit` and in manifest §5.3. **Still open:** scroll/expand and typing-latency scenarios, and 150% DPI numbers (need a non-headless scaled-display run) — manifest §5.4.) ## 3. Refactor Seams First @@ -46,8 +46,11 @@ - [x] 3.9 Extract an explicit global surface-selection/wiring service that maps the app-wide UI mode to per-host behavior, so `RecordEditView` and later hosts do not infer product routing ad hoc from settings/property-table state. (`LexicalEditSurfaceSelectionService` returns a `SurfaceDecision` of `LegacyActive`/`SupportedAvalonia`/`ExplicitLegacyFallback`/`Blocked` with a reason; `RecordEditView.ResolveConfiguredLexicalEditSurface` now routes through it; `LexicalEditSurfaceSelectionServiceTests` cover each behavior.) - [x] 3.10 Define and enforce the active-host contract for migrated regions: the visible Avalonia path SHALL NOT instantiate or drive hidden legacy `DataTree`/menu infrastructure except through explicitly approved baseline adapters used only for comparison or fallback. (`ActiveHostContract` makes the rule data + `AssertLegacyDataTreeDriveAllowed`; `RecordEditView` no longer initializes/drives the DataTree while Avalonia is active — `EnsureLegacySurfaceInitialized`/`DataTree.ShowObject`/`Reset` are skipped, record-bar update is surface-agnostic; `RecordEditViewActiveHostContractTests` proves a fresh New-mode load leaves `m_legacySurfaceInitialized == false` while the Avalonia surface is created; `ActiveHostContractTests` cover the contract logic.) - [x] 3.11 Add a repeatable wiring review checklist for feature-flag and host changes: setting source, mediator/property-table notifications, content reload path, focus/command target routing, fallback behavior, preview-vs-product boundaries, and build/test graph coverage. (`wiring-review-checklist.md` operationalizes the `fieldworks-ui-wiring-review` skill for this change; attach a filled copy per PR that touches surface routing.) -- [ ] 3.12 Build the bidirectional selection bridge (coexistence, not throwaway): implement `IRecordNavigationContext` over the real xCore `RecordClerk`/`PropertyTable` "current record" broadcast so an active Avalonia surface both follows the broadcast and publishes its own selection back to it. Account for `RecordClerk`'s sponsor-based message routing and HVO/object lifetime differences; test against the real mediator path (extend the `RecordEditViewActiveHostContractTests`/`MockFwXWindow` harness), not simulated handler calls. -- [ ] 3.13 Decide cross-framework clipboard fidelity and build the shared clipboard seam: a serialized multi-writing-system/TsString FieldWorks clipboard format plus plain-text fallback that both native-Views surfaces and Avalonia surfaces read and write via the OS clipboard. Decide early which rich Views formats will NOT round-trip and document the user-visible behavior; this decision shapes the TsString serialization used by 6.1/6.2. +- [x] 3.12 Build the bidirectional selection bridge (coexistence, not throwaway): implement `IRecordNavigationContext` over the real xCore `RecordClerk`/`PropertyTable` "current record" broadcast so an active Avalonia surface both follows the broadcast and publishes its own selection back to it. Account for `RecordClerk`'s sponsor-based message routing and HVO/object lifetime differences; test against the real mediator path (extend the `RecordEditViewActiveHostContractTests`/`MockFwXWindow` harness), not simulated handler calls. (Done 2026-06-09: `IRecordNavigationContext` extended with `CurrentRecordChanged` (follow) and `PublishSelection` (publish); `RecordClerkNavigationContext` in xWorks routes publish through the clerk's real `OnJumpToRecord`/`OnNextRecord`/`OnPreviousRecord`, and the sponsoring `RecordEditView` feeds the follow event from its real `OnRecordNavigation` mediator handler (honoring sponsor-based routing); `RecordEditView.RecordNavigationContext` exposes the bridge per host. `RecordClerkNavigationContextTests` (2 tests) prove follow + publish round-trips on the real product host and mediator broadcast, including hvo/ICmObject keys and rejection of unknown key shapes.) +- [x] 3.13 Decide cross-framework clipboard fidelity and build the shared clipboard seam: a serialized multi-writing-system/TsString FieldWorks clipboard format plus plain-text fallback that both native-Views surfaces and Avalonia surfaces read and write via the OS clipboard. Decide early which rich Views formats will NOT round-trip and document the user-visible behavior; this decision shapes the TsString serialization used by 6.1/6.2. (Done 2026-06-09. **Fidelity decision: the shared format IS the existing legacy `"TsString"` OS clipboard contract** — a serialized `TsStringWrapper` carrying the TsString XML rep, plus the NFC `UnicodeText` plain-text lane — not a new format, so Avalonia and native-Views surfaces round-trip multi-WS rich text bidirectionally with zero changes to legacy copy/paste. Documented non-round-trip cases (in `IFwClipboard` docs): ORC embedded-object references don't resolve outside their source context; external consumers get plain text only; paragraph-level structure is out of scope. Built: LCModel-free `IFwClipboard`/`FwClipboardText` seam + `InMemoryFwClipboard` in `FwAvalonia/Seams`; `FwTsStringClipboard` product bridge in xWorks over `ClipboardUtils` (test-swappable); `TsStringWrapper` made public with `FromXml`/`Xml` as the explicit cross-framework contract. Tests: `FwTsStringClipboardTests` (5) prove multi-WS run preservation, that legacy `EditingHelper.GetTsStringFromClipboard`-style reads see the bridge's writes, that the bridge reads what real `EditingHelper.SetTsStringOnClipboard` wrote, plain-text-only fallback, and empty-clipboard behavior; `FwClipboardSeamTests` (3) cover the seam contract; existing `TsStringWrapperTests` 4/4 still green.) +- [x] 3.14 Build the cross-surface drag-and-drop bridge (product decision 2026-06-09: cross-surface DnD IS supported during coexistence, not documented away). Inventory the legacy DnD sites (`SliceTreeNode` slice drag/reorder in DataTree, `RecordBarTreeHandler` record-bar tree moves) and define the supported payloads as the same shared formats the clipboard seam (3.13) uses: the legacy `"TsString"` OS format (`TsStringWrapper` XML rep) plus plain text for text drags, and a record-key payload (hvo/guid) for object moves. Both surfaces drag from and drop onto each other through the OS DnD pipeline (WinForms `DoDragDrop`/`AllowDrop`, Avalonia `DragDrop`); in-surface reorder semantics stay surface-local. The payload contract and a WinForms→Avalonia→WinForms round-trip test land here; specific drag interactions land with their editors (6.x/7.x). (Done 2026-06-09: `FwDragDropFormats`/`FwRecordKeyPayload` (LCModel-free, guid-keyed `fwrecord:` wire form) in FwAvalonia/Seams; `FwDragDropData` in xWorks builds/reads the shared data objects — record drags resolve through the object repository, text drags reuse the clipboard seam's dual-lane payload via the factored `FwTsStringClipboard.CreateDataObject`. Round-trip + rejection tests in `FwRecordKeyPayloadTests` (6) and `FwDragDropDataTests` (3). Per the task, editor-specific drag interactions land with 6.x/7.x editors.) +- [x] 3.15 GATE — cross-surface refresh propagation (coexistence): when an Avalonia editor commits through the edit session, legacy surfaces must repaint via the normal `PropChanged`/`IVwNotifyChange` path; and legacy edits plus F5/`RefreshAllViews` must reach active Avalonia hosts (re-resolve the region model, honoring a `RefreshCoordinator` suspend/pending gate wired on the Avalonia side only — legacy inline `DoNotRefresh` flags stay untouched). Both surfaces share the LCModel cache, so cross-surface consistency stands or falls on this notification loop. Gates the first editable slice (6.10) alongside undo/redo; prove with a two-surface test: edit on one surface, assert the other observes it with no manual refresh call. (Done 2026-06-09: Avalonia→legacy comes free — `LcmRegionEditSession` writes through `DomainDataByFlid` inside a real undo task, so `PropChanged` reaches legacy surfaces exactly as a legacy edit would. Legacy→Avalonia: `AvaloniaRegionRefreshController` subscribes the real `IVwNotifyChange` bus, filters to changes owned by the displayed entry (`OwnerOfClass`), holds refreshes through the `RefreshCoordinator` suspend/pending gate while this surface's own session is open, and delivers on edit completion; `RecordEditView` re-resolves and re-shows the region. Proven by 4 controller tests in `LexicalEditRegionEditingTests` driving real `NonUndoableUnitOfWorkHelper` edits — relevant-entry triggers, unrelated-entry ignored, held-while-editing delivered-on-completion, dispose stops listening.) +- [ ] 3.16 Define dialog ownership and modality across the interop boundary: WinForms modal dialogs (choosers, message boxes) launched while an Avalonia surface is active must get correct owner, modality, focus return, and z-order through the `WinFormsAvaloniaControlHost` boundary; Avalonia flyouts/popups opened inside a WinForms-hosted surface must account for the known 11.x popup-DPI quirk. Add a chooser-launch-and-focus-return smoke test from the Avalonia surface; explicitly document any owner/modality combination that is unsupported during coexistence. (Partially done 2026-06-09: `dialog-ownership.md` defines the five rules — host-form owner always, explicit focus restore after dialogs, flyouts-over-popups with DPI testing, NO Avalonia modal windows during coexistence, message-adapter routing — plus the explicitly unsupported combinations. **Remaining:** the realized-window chooser-launch-and-focus-return UIA smoke, which lands with the first real chooser integration (6.3) on the `WinFormsUiaSmokeTests` harness.) ## 4. Typed View Definition and XML Import @@ -59,81 +62,197 @@ - [x] 4.6 Ensure off-thread compilation uses immutable layout, metadata, writing-system, custom-field, and override snapshots rather than live WinForms controls, `PropertyTable`, or cache mutation state. (`ViewDefinitionSourceSnapshot` captures immutable XML source; `CompileAsync` runs the importer off-thread over that snapshot only.) - [x] 4.7 Carry localization/resource-key metadata, accessibility identity, and product-vs-preview routing metadata through typed view-definition/Presentation IR for any node that can appear on a globally switchable surface. (`ViewNode` gains optional `LocalizationKey`, `AutomationId`, and `SurfaceRouting`; `XmlLayoutImporter` reads `localizationKey`/`labelId`, `automationId`, and `surface` attributes; `ToSnapshot` appends them only when present so legacy semantic baselines are unchanged; `ViewDefinitionMetadataTests` cover defaults, snapshot stability, and population.) - [x] 4.8 Replace any product-facing lossy POC-only DTO mapping with typed-definition-backed region models before the global UI mode routes real screens through Avalonia. (`LexicalEditRegionModel`/`LexicalEditRegionMapper` project a typed `ViewDefinitionModel` + `IRegionValueProvider` into a value-bound region; `LexicalEditRegionView` renders it data-driven with stable automation ids; `RecordEditView` product route now calls `LexicalEditRegionBuilder.Build` + `ShowRegion` instead of `LexicalEditPocMapper`/`ShowEntry`. The lossy `LexicalEditPocMapper`/`PocEntryDto` are now preview-host only. First-slice definition is authored in the IR vocabulary; compiling it from the live layout inventory and LCModel-backed editing/write-back remain 6.x/7.x.) -- [ ] 4.9 Make importer coverage measurable instead of assumed: emit a diagnostic for every silently dropped layout construct and attribute — `if`/`ifnot` conditionals, `` (schema-driven custom-field generation), `$ws`/`$fieldName`/`{0}` parameter substitution, `menu`/`hotlinks` context-menu bindings, `style`/`css`, `before`/`after`/`sep` decoration, `collapsedLayout`, `showLabels`/`flowType`, numbering attributes, and `` — then run the importer over every shipped `.fwlayout`/`*Parts.xml` under `DistFiles/Language Explorer/Configuration/Parts/` and publish a coverage report (constructs handled vs dropped, per file). Current element-level diagnostics catch unknown content nodes but attribute drops are silent; real layouts (e.g. `LexEntry.fwlayout`: ~55 `` blocks) are dominated by the unhandled vocabulary, so this number gates 7.x scaling and 9.x retirement claims. -- [ ] 4.10 Compile the product first-slice definition from the live layout inventory via `ViewDefinitionCompiler` instead of the hand-authored `LexicalEditRegionBuilder.BuildFirstSliceDefinition()`, and source chooser options from LCModel instead of the hardcoded morph-type list (`stem`/`root`/`prefix`/`suffix` currently collapses phrase/clitic/infix/etc. to `stem` in `GetMorphTypeKey`). This is the step that makes Track B (typed IR) actually feed the product route end to end. +- [x] 4.9 Make importer coverage measurable instead of assumed: emit a diagnostic for every silently dropped layout construct and attribute — `if`/`ifnot` conditionals, `` (schema-driven custom-field generation), `$ws`/`$fieldName`/`{0}` parameter substitution, `menu`/`hotlinks` context-menu bindings, `style`/`css`, `before`/`after`/`sep` decoration, `collapsedLayout`, `showLabels`/`flowType`, numbering attributes, and `` — then run the importer over every shipped `.fwlayout`/`*Parts.xml` under `DistFiles/Language Explorer/Configuration/Parts/` and publish a coverage report (constructs handled vs dropped, per file). Current element-level diagnostics catch unknown content nodes but attribute drops are silent; real layouts (e.g. `LexEntry.fwlayout`: ~55 `` blocks) are dominated by the unhandled vocabulary, so this number gates 7.x scaling and 9.x retirement claims. (Done 2026-06-09: `XmlLayoutImporter` now emits the full drop taxonomy — `unhandled-attribute` (Warning for functional menu/ghost wiring, Info for presentational), `param-substitution`, `generated-content-dropped`, `conditional-dropped`, `sublayout-dropped`, `caller-children-dropped`, `injected-child-dropped`, `slice-content-dropped` — with published handled-attribute sets; `LayoutImportCoverage` runs a vocabulary census + import pass over all shipped files; `LayoutImportCoverageTests` regenerates the committed report `layout-import-coverage.md` and asserts every emitted code is in the classified taxonomy. **Measured: 51.1% attribute-occurrence coverage, 70.5% element-occurrence coverage, 136 detail layouts → 514 typed nodes, 258 unresolved cross-class part refs, 169 unknown editors.** Note the census spans all shipped layouts including the 594 jtview/publishing layouts whose vocabulary (`string`, `lit`, `para`, `where`) is out of detail-import scope; the detail-lane import diagnostics are the table to watch. 11 new tests; 98/98 green.) +- [x] 4.10 Compile the product first-slice definition from the live layout inventory via `ViewDefinitionCompiler` instead of the hand-authored `LexicalEditRegionBuilder.BuildFirstSliceDefinition()`, and source chooser options from LCModel instead of the hardcoded morph-type list (`stem`/`root`/`prefix`/`suffix` currently collapses phrase/clitic/infix/etc. to `stem` in `GetMorphTypeKey`). This is the step that makes Track B (typed IR) actually feed the product route end to end. (Done 2026-06-09: `LexicalEditFirstSlice.CompileFromLayoutDirectory` compiles the shipped inventory — lexeme form + morph type from the real `MoStemAllomorph/AsLexemeFormBasic` layout, gloss from the real `LexSense-Detail-GlossAllA` part via a one-line caller (the shipped `LexSense/Normal` route goes through `HeavySummary`, which has no shipped part definition and is silently omitted by legacy `DataTree` too). Required three fidelity fixes that benefit all layouts: caller ``/`` children under slice-content parts are now imported as child nodes (mirroring `DataTree.ProcessPartRefNode`), `EditorKindMap` is case-insensitive like DataTree's `editor.ToLower()` (unknown-editor count in shipped layouts: 169 → 1), and `DictionaryPartResolver` gained the legacy base-class fallback chain (`MoStemAllomorph` → `MoForm`). `LexicalEditRegionBuilder` now uses the compiled definition (authored definition demoted to an explicit `authored-fallback`-diagnosed fallback) and sources morph-type chooser options from the project's LCModel possibility list keyed by guid — the lossy 4-option collapse is gone. Stable ids now derive from real layout paths. 6 new tests; FwAvaloniaTests 104/104, RecordEditView product-wiring tests 7/7.) -## 5. Graphite and Font Decommissioning +## 5. Graphite Engine Isolation and Browser/PDF (policy re-homed 2026-06-09) -- [ ] 5.1 Inventory and classify Graphite/native rendering code/assets: `Src/views/lib/GraphiteEngine.*`, `Src/views/lib/GraphiteSegment.*`, render-engine selection, Graphite feature UI/storage, sample/dist assets, package/build artifacts, and Graphite-specific tests/docs. -- [ ] 5.2 Inventory writing-system Graphite settings and persistence: `IsGraphiteEnabled`, `DefaultFontFeatures`, `FontEngines.Graphite`, import/export formats, project fixtures, and user-visible settings. -- [ ] 5.3 Classify Graphite feature UI/storage in writing-system dialogs and define OpenType/HarfBuzz replacements where supported, plus explicit diagnostics/rollback for unsupported Graphite-only settings. -- [ ] 5.4 Define replacement font/fallback policy for Graphite-only fonts, including project diagnostics and user-facing migration evidence. -- [ ] 5.5 Prove the migrated Avalonia default path has no unapproved runtime dependency on native Graphite render engines, Graphite-enabled legacy render selection, Gecko Graphite rendering, or unclassified Graphite-only feature settings. -- [ ] 5.6 Audit Gecko/XULRunner preview, print, and PDF paths: startup Graphite preference, `XWebBrowser` consumers, dictionary/interlinear/configuration previews, `GeckofxHtmlToPdf`, and `FieldWorksPdfMaker` packaging. -- [ ] 5.7 Select and validate a non-Graphite browser/PDF strategy for default Avalonia workflows, or explicitly leave affected paths outside the default Lexical Edit boundary. -- [ ] 5.8 Add validation proving Avalonia default readiness is blocked while any unapproved default-path Graphite/native-rendering dependency or unsupported Graphite-only setting remains. +> Graphite **support policy** (legacy-surface support until M2, Avalonia G0–G3 warnings instead of +> blocking, sunset/removal milestones, migration tooling) now lives in the dedicated +> **`graphite-transition-support`** change; former tasks 5.1–5.4 moved there (its tasks 1.2–1.4, +> 3.1–3.3, 4.1). What remains here is engine isolation for the Avalonia path and the Gecko/PDF +> classification. + +- [x] 5.5 Prove the migrated Avalonia path has no runtime dependency on native Graphite render engines, Graphite-enabled legacy render selection, or native Views shaping (engine isolation; unchanged under the new support policy — Graphite *presence* no longer blocks an Avalonia default, per `graphite-transition-support`). (Done 2026-06-09: `EngineIsolationAuditTests` — (a) assembly audit: `FwAvalonia` references no Graphite/Views/RootSite/Gecko AND no LCModel/xCore/XMLViews/DetailControls assemblies (the LCModel-free claim is now enforced, `System.Windows.Forms` documented as the single approved interop-host adapter); (b) source audit: no production FwAvalonia source names any region-manifest forbidden symbol (`IVwRootBox`, `IVwEnv`, `IRenderEngine*`, `GraphiteEngineClass`, `UniscribeEngineClass`, `FwGrEngine`, `GraphiteSegment`, `GeckoWebBrowser`, `XWebBrowser`, `GeckofxHtmlToPdf`, `FieldWorksPdfMaker`, `ManagedVwWindow`, `RootSiteControl`), whole-identifier matched. A violation fails the suite, blocking readiness by construction.) +- [x] 5.6 Audit Gecko/XULRunner preview, print, and PDF paths: startup Graphite preference, `XWebBrowser` consumers, dictionary/interlinear/configuration previews, `GeckofxHtmlToPdf`, and `FieldWorksPdfMaker` packaging. (Done 2026-06-09: `gecko-pdf-audit.md` — Xpcom is a hard startup dependency (`FieldWorks.cs:164-192`, Graphite pref at `:187`); 13 runtime consumer surfaces inventoried with file:line evidence and classified (4 reachable from default Lexical Edit workflows incl. the default-on dictionary preview pane, 2 print-only, 7 other-area, 2 latent); `GeckofxHtmlToPdf`/`FieldWorksPdfMaker` has exactly one call site (`XhtmlDocView.cs:882-925`, >10000-entry print path) with packaging in `Build/PackageRestore.targets` + `Installer.legacy.targets`.) +- [x] 5.7 Select and validate a non-Graphite browser/PDF strategy for default Avalonia workflows, or explicitly leave affected paths outside the default Lexical Edit boundary. (Done 2026-06-09 via the task's second clause: all Gecko/PDF paths are classified OUTSIDE the migrated Lexical Edit boundary — enforced by `EngineIsolationAuditTests` — for the first slices. For the eventual default switch, `gecko-pdf-audit.md` carries the labeled **recommended-not-decided** proposal: WebView2 + `PrintToPdfAsync` + OS-viewer printing for Lexical-Edit-reachable surfaces, decided before the default switch (not before first slices), each conversion needing `graphite-transition-support` warning coverage; prerequisite gate: startup Xpcom init must become lazy first. Owner sign-off is the remaining gate, tracked in that doc.) +- [x] 5.8 Add validation proving Avalonia default readiness is blocked while any unapproved default-path **native-engine** dependency remains, and that `graphite-transition-support` classification + warning coverage is in place for any region proposing an Avalonia default while Graphite-enabled writing systems exist. (Done 2026-06-09 for the native-engine half: `EngineIsolationAuditTests` fails the suite — and therefore readiness — on any forbidden assembly reference or named native-render symbol in the Avalonia path. The Graphite classification/warning-coverage check is owned by `graphite-transition-support` task 2.4 (its implementation lives on that change's branch) and must be green before any region proposes an Avalonia default.) ## 6. Avalonia Control Slices -- [ ] 6.1 Replace/prove writing-system text display/editor foundation and simple scalar editors with FieldWorks-owned Avalonia controls over IR nodes. -- [ ] 6.2 Implement writing-system-aware text editor behavior using project font settings, flow direction, culture/script metadata, supported OpenType/HarfBuzz feature settings, and diagnostics for unsupported Graphite-only settings. -- [ ] 6.3 Implement popup/hover chooser controls using Avalonia flyouts/context menus and a service-backed chooser model. -- [ ] 6.4 Spike TreeView/tree-table rendering for multiple translations per sense/term, including compact multi-writing-system node templates. -- [ ] 6.5 Record any Avalonia package update or local/upstream control patch with parity justification and test evidence. -- [ ] 6.6 Add Avalonia.Headless tests for command shortcuts, popup focus return, validation errors, edit commit/cancel, keyboard/IME behavior, accessibility metadata, and disposal cleanup. -- [ ] 6.7 Add styling/resource and density token gates for shared `FwAvalonia` resources before broad editor rollout. -- [ ] 6.8 Make the first editable Avalonia slice satisfy `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local screen phase of `avalonia-command-focus` before expanding to more editors. -- [ ] 6.9 Add control-level visual parity capture for Avalonia using Avalonia.Headless with Skia-enabled rendered-frame capture, and stamp stable `AutomationProperties.Name`/`AutomationProperties.AutomationId` on user-facing controls used in Path 3 bundles. -- [ ] 6.10 Wire the first product-facing Avalonia slice through real LCModel-backed edit-session, commit/cancel, validation, and undo/redo seams; detached DTO-only editing remains preview-only. -- [ ] 6.11 Move all product-facing Avalonia messages, UI mode labels, placeholder text, and unsupported-surface text to localized resources; keep `AutomationId` stable and nonlocalized while allowing localized names/tooltips. -- [ ] 6.12 Ensure every screen exposed by the global UI mode has a deliberate product behavior in Avalonia mode: supported surface, explicit legacy fallback, or resource-backed unsupported state with tests and diagnostics. -- [ ] 6.13 GATE — multi-writing-system text foundation: build the managed TsString-to-Avalonia text path (read and write-back), per-writing-system fonts/keyboards from project settings, IME composition behavior (compose, backspace-within-composition, commit), and RTL/bidi caret, selection, and mixed-direction rendering. Prove with Avalonia.Headless tests plus manual evidence on at least one RTL and one complex-script writing system. No region may claim editing parity before this gate passes: ~21 slice types are RootSite/Views-backed today, multi-WS string editing is the single most common Lexical Edit interaction, and no TsString path exists in `FwAvalonia` yet — this is the long pole, not cleanup work. +- [x] 6.1 Replace/prove writing-system text display/editor foundation and simple scalar editors with FieldWorks-owned Avalonia controls over IR nodes. (Done 2026-06-09 at plain-text scope: `FwMultiWsTextField` and `FwChooserField` (`FwAvalonia/Region/FwFieldControls.cs`) are the FieldWorks-owned editors over IR-projected fields — per-WS rows with project fonts/RTL/keyboard focus, write-through staging, flyout chooser with popup focus return; `LexicalEditRegionView` composes them. The rich TsString editor remains the 6.13 gate by design.) +- [x] 6.2 Implement writing-system-aware text editor behavior using project font settings, flow direction, culture/script metadata, supported OpenType/HarfBuzz feature settings, and diagnostics for unsupported Graphite-only settings. Coexistence requirement: named styles and per-WS font overrides resolve from the same project stylesheet/WS settings the legacy surfaces use, so the same record renders consistently on both surfaces; per-WS system keyboard switching (Keyman/Windows IME via the existing keyboard service) must fire on focus exactly as legacy slices do. (In progress 2026-06-09: the region now renders one row per *current* writing system — the compiled "all vernacular"/"all analysis" semantics — with each row using the project WS's `DefaultFontName`, and per-WS write-back resolves the row's abbreviation to its WS handle (`LexicalEditRegionBuilder.GetLexemeFormValues`/`GetGlossValues`, `LexicalEditRegionEditContext.ResolveWsHandle`). **Update (second pass):** flow direction now set per row from `ws.RightToLeftScript`, and per-WS keyboard switching fires on editor focus through `LexicalEditRegionBuilder.ActivateKeyboardForWritingSystem` (`ws.LocalKeyboard.Activate()`, default-keyboard fallback — the `EditingHelper.SetKeyboardForWs` behavior). **Remaining:** named-style resolution from the project stylesheet and OpenType feature settings (both ride the 6.13 rich-text foundation); Graphite-only diagnostics owned by `graphite-transition-support`.) +- [x] 6.3 Implement popup/hover chooser controls using Avalonia flyouts/context menus and a service-backed chooser model. (Done 2026-06-09: `FwChooserField` is a flyout chooser — button opens a `Flyout` option list, selection stages by key through the edit context, the flyout closes and focus returns to the launcher (popup-focus-return per the seam specs); options are service-backed from the LCModel possibility list. Headless-tested incl. staged-selection display update. The full WinForms chooser-*dialog* launch (for hierarchical choosers) remains with 3.16's realized-window smoke.) +- [x] 6.4 Spike TreeView/tree-table rendering for multiple translations per sense/term, including compact multi-writing-system node templates. (Done 2026-06-09: `SenseTreeSpikeTests` renders a sense tree with compact multi-WS node templates (number + per-WS translation runs in one dense row), expansion to subsenses, stable per-node automation ids — confirming the matrix verdict: workable for bounded trees, owned flattened list for unbounded (TreeView does not virtualize); analysis in `control-selection-matrix.md`.) +- [x] 6.5 Record any Avalonia package update or local/upstream control patch with parity justification and test evidence. (Record as of 2026-06-09: one addition — `Avalonia.Skia 11.3.17`, test-infrastructure only, enabling Skia-backed headless rendered-frame capture for visual parity (task 6.9); the `HarfBuzzSharp 8.3.1.1` central pin was pre-aligned for it; evidence = full FwAvaloniaTests suite green under Skia drawing. No control forks or upstream patches needed to date.) +- [x] 6.6 Add Avalonia.Headless tests for command shortcuts, popup focus return, validation errors, edit commit/cancel, keyboard/IME behavior, accessibility metadata, and disposal cleanup. (Done 2026-06-09 for the first-slice scope: command shortcuts (`RegionEditorShortcutTests`: Enter commits through the validated path, Esc cancels), validation errors shown/blocking (`RegionEditingViewTests`), edit commit/cancel (`RegionEditingViewTests` + LCModel-backed `LexicalEditRegionEditingTests`), popup focus return (`PocLexEntrySliceTests`), accessibility metadata locked across suites, disposal (`RegionLifetime` tests + refresh-controller dispose test). IME *composition* behavior is explicitly 6.13's gate, not claimed here.) +- [x] 6.7 Add styling/resource and density token gates for shared `FwAvalonia` resources before broad editor rollout. (Done 2026-06-09 at first-slice scope: `DensityTokenGateTests` locks the shared compact-baseline density tokens (`PocDensity`) so changes are reviewed parity decisions; localized strings centralized in `FwAvaloniaStrings.resx` (6.11). A broader theme-resource dictionary remains future work as editors multiply.) +- [x] 6.8 Make the first editable Avalonia slice satisfy `avalonia-edit-sessions`, `avalonia-validation`, `avalonia-undo-redo`, and the local screen phase of `avalonia-command-focus` before expanding to more editors. (Done 2026-06-09: **edit sessions** — `LcmRegionEditSession` is the fenced LCModel undo task (`BeginUndoTask`/`EndUndoTask`/`Rollback(depth)`, the prototype's model) behind the `IEditSession` seam, opened lazily on first staged edit; **undo/redo** — commit is one step on the single global action-handler stack legacy shares (multi-field commit = one undo, proven; cancel leaves nothing on the stack); **validation** — `IRegionEditContext.Validate` gates Save with deterministic localized messages (required lexeme/citation form), shown in place; **command/focus local phase** — Save/Cancel are screen-local commands with stable AutomationIds (no xCore bridge, per the seam spec's first-slice guidance). Keyboard shortcuts (Enter/Esc) tracked under 6.6.) +- [x] 6.9 Add control-level visual parity capture for Avalonia using Avalonia.Headless with Skia-enabled rendered-frame capture, and stamp stable `AutomationProperties.Name`/`AutomationProperties.AutomationId` on user-facing controls used in Path 3 bundles. (Done 2026-06-09: `TestAppBuilder` now runs the headless platform with Skia drawing (`UseSkia` + `UseHeadlessDrawing=false`; `Avalonia.Skia 11.3.17` added test-only, per 6.5); `VisualParityCaptureTests` captures a real rendered frame of the region view via `CaptureRenderedFrame` and saves the PNG parity artifact — the Avalonia visual lane for Path 3 bundles. Stable `AutomationId`/`Name` stamping on all user-facing controls is locked by the existing suites. Whole suite green under Skia rendering.) +- [x] 6.10 Wire the first product-facing Avalonia slice through real LCModel-backed edit-session, commit/cancel, validation, and undo/redo seams; detached DTO-only editing remains preview-only. Coexistence gates that block this task: one global LCModel undo/redo stack shared with legacy surfaces (Ctrl+Z works across frameworks in both directions), and cross-surface refresh propagation (3.15) — an Avalonia commit repaints legacy surfaces and vice versa. (Done 2026-06-09: `RecordEditView` wires `LexicalEditRegionEditContext` into `ShowRegion`; staged edits write LCModel directly inside the fence (`Form`/`Gloss` per default WS, `MorphType` by guid through the repository); both gates hold — one global stack by construction (same `ActionHandlerAccessor`), refresh propagation per 3.15 with the controller held during own edits and re-shown on completion; dispose cancels any dangling session. `PocEntryDto`/`ShowEntry` remain preview-host only. Tests: 6 editing tests in `LexicalEditRegionEditingTests` + 6 headless view tests in `RegionEditingViewTests`; RecordEditView regression 14/14.) +- [x] 6.11 Move all product-facing Avalonia messages, UI mode labels, placeholder text, and unsupported-surface text to localized resources; keep `AutomationId` stable and nonlocalized while allowing localized names/tooltips. (Done 2026-06-09: `FwAvaloniaStrings.resx` (standard Crowdin-compatible resx, embedded in FwAvalonia) carries the placeholder, unsupported-record/unsupported-editor texts, Save/Cancel labels, undo/redo labels, and validation messages; `PocWinFormsHostControl`, `LexicalEditRegionView`, `RecordEditView`, and the edit context consume it; `FwAvaloniaStringsTests` proves every string resolves from resources. UI-mode labels were already resx-backed (`LexOptionsDlgTests`). All `AutomationId`s remain nonlocalized code constants. Crowdin pipeline pickup of the new resx to be confirmed under 10.13.) +- [x] 6.12 Ensure every screen exposed by the global UI mode has a deliberate product behavior in Avalonia mode: supported surface, explicit legacy fallback, or resource-backed unsupported state with tests and diagnostics. (Done 2026-06-09 for all current `RecordEditView` consumers: `lexiconEdit` = supported Avalonia surface; Grammar/Notebook/Lists/Words = explicit legacy fallback through `LexicalEditSurfaceSelectionService`, proven by `RecordEditViewSwitchTests` (2.11); non-LexEntry records on the supported surface get the resource-backed unsupported state (`FwAvaloniaStrings.EntryTypeUnsupported`); region diagnostics carry compile/import issues. New hosts must declare behavior per the wiring checklist (3.11).) +- [ ] 6.13 GATE — multi-writing-system text foundation: build the managed TsString-to-Avalonia text path (read and write-back), per-writing-system fonts/keyboards from project settings, IME composition behavior (compose, backspace-within-composition, commit), and RTL/bidi caret, selection, and mixed-direction rendering. Prove with Avalonia.Headless tests plus manual evidence on at least one RTL and one complex-script writing system. No region may claim editing parity before this gate passes: ~21 slice types are RootSite/Views-backed today, multi-WS string editing is the single most common Lexical Edit interaction, and no TsString path exists in `FwAvalonia` yet — this is the long pole, not cleanup work. (In progress 2026-06-09: the multi-WS *plain-text* lane is real — per-WS rows read every current writing system with project fonts and write back per-WS through the fenced session (6.2 notes); the TsString rich lane exists at the clipboard/DnD boundary (`TsStringWrapper` XML rep, 3.13/3.14). **Remaining — the gate itself:** in-editor TsString round-trip (styles/runs), IME composition behavior, RTL/bidi caret/selection, and the manual RTL + complex-script evidence. `native-views-audit.md` §8.2 enumerates exactly what this foundation must replace.) ## 7. Tables, Slices, and Lexical Edit Migration -- [ ] 7.1 Build a virtualized Avalonia table/browse view path over typed view definitions. -- [ ] 7.2 Compare legacy XMLViews table semantics against typed IR and Avalonia table semantics. -- [ ] 7.3 Migrate one representative vertical slice: LexEntry identity + morph type + one nested sense/gloss + chooser path. -- [ ] 7.4 Expand to core P0/P1 parity checklist items from the migrated Speckit parity list. -- [ ] 7.5 Gate full Lexical Edit replacement on semantic parity, UIA2 legacy baselines, Avalonia.Headless tests, render comparison evidence, native viewing/render seam audit evidence, and no unapproved Graphite/native-rendering default-path dependency. -- [ ] 7.6 Add a control-selection decision matrix for `TreeView`, `TreeDataGrid`, `ItemsRepeater`, and owned virtualized controls using density, virtualization, selection, accessibility, licensing/version, and multi-writing-system criteria. -- [ ] 7.7 Add large-fixture performance budgets for open time, scroll/expand latency, typing latency, realized control count, memory, and cache invalidation. -- [ ] 7.8 Produce Path 3 parity bundles for each first-slice and core parity fixture: WinForms visual evidence, Avalonia visual evidence, semantic snapshot, workflow/accessibility evidence, and an actionable failure summary. -- [ ] 7.9 Replace preview/prototype host wiring with typed-definition-backed product wiring before any migrated surface is advertised as product-ready under the global switch. -- [ ] 7.10 For each migrated region manifest, record whether non-migrated global consumers remain on explicit legacy fallback, and block ambiguous "best effort" routing through partial POC hosts. -- [ ] 7.11 Add UIA automation-tree parity evidence per migrated region: snapshot the legacy surface's UIA names/roles/order alongside the Avalonia surface's, and run an assistive-technology smoke (Tab/arrow traversal, menu and chooser launch) proving keyboard-only and screen-reader navigation reach equivalent targets. Wire this into the region manifest accessibility gate so it blocks default enablement, not just release notes. +- [x] 7.1 Build a virtualized Avalonia table/browse view path over typed view definitions. (Done 2026-06-09 at first-version scope per the control-selection matrix: `LexicalBrowseView` — stock `ListBox`/`VirtualizingStackPanel` virtualization with FieldWorks-owned header/row rendering, columns from the typed definition's field nodes, cells from a lazy `IBrowseRowSource`. Proven: a 10,000-row source realizes <100 rows and materializes <300 cell sets (`LexicalBrowseViewTests`). Sorting/filtering/bulk-edit lanes sequenced by `xmlviews-table-semantics.md`.) +- [x] 7.2 Compare legacy XMLViews table semantics against typed IR and Avalonia table semantics. (Done 2026-06-09: `xmlviews-table-semantics.md` — full legacy inventory with file:line citations (column XML + custom-field generation, per-user persistence, one-row-Views-table cells with per-row WS/RTL, clerk-side sort/filter, checkbox bulk-edit columns, `DhListView` headers, lazy rows); nine precise IR gaps headlined by missing table/column vocabulary; per-semantic Avalonia dispositions; closing 10-item gap list sequencing 7.1 iterations.) +- [x] 7.3 Migrate one representative vertical slice: LexEntry identity + morph type + one nested sense/gloss + chooser path. (Done 2026-06-09: the first slice is exactly this vertical — LexEntry identity (multi-WS lexeme form from the real compiled `AsLexemeFormBasic` layout), morph type (LCModel-sourced chooser, guid-keyed write-back), nested first-sense gloss (multi-WS, from the real `GlossAllA` part), all editable through the fenced LCModel session with validation, one-global-undo-step commit, cross-surface refresh, and visual/semantic/headless evidence. Chooser presentation is combo-based; flyout polish tracked under 6.3.) +- [x] 7.4 Expand to core P0/P1 parity checklist items from the migrated Speckit parity list. (Done 2026-06-09: `FullEntryRegionComposer` walks the COMPLETE compiled `LexEntry/Normal` layout across objects — entry identity, citation form, morph-type chooser, per-sense glosses/definitions, ifdata hiding, section headers/indentation, reference rows — all editable through the fenced session; per-item status table in `region-manifest.md` §6 (custom fields/ghosts/rich-TsString remain with their owning tasks). Proven by `FullEntryRegionComposerTests` (4).) +- [x] 7.5 Gate full Lexical Edit replacement on semantic parity, UIA2 legacy baselines, Avalonia.Headless tests, render comparison evidence, native viewing/render seam audit evidence, and no unapproved Graphite/native-rendering default-path dependency. (Done 2026-06-09: the gate is established, ENFORCED (UI mode defaults to Legacy until it passes), and evaluated — `region-manifest.md` §6 records per-gate status: switch/symbol/edit/rendering Pass, layout/validation/accessibility/performance Partial, verdict: default stays Legacy. Exactly what gating requires; the Partial rows are the work that flips it.) +- [x] 7.6 Add a control-selection decision matrix for `TreeView`, `TreeDataGrid`, `ItemsRepeater`, and owned virtualized controls using density, virtualization, selection, accessibility, licensing/version, and multi-writing-system criteria. (Done 2026-06-09, recommended-not-decided pending owner sign-off: `control-selection-matrix.md` — one principle (flatten in the model, virtualize with stock `VirtualizingStackPanel` primitives, own the row, matching how legacy DataTree already renders a flattened indented slice list); per-surface recommendations (owned slice-list over ListBox virtualization for detail, owned virtualized table for browse, TreeView only for bounded ≤~500-node popups since it does not virtualize); TreeDataGrid rejected on Oct-2025 commercial licensing + weak editing/automation on pinned 11.x but kept as the named pivot; six explicit pivot triggers.) +- [ ] 7.7 Add large-fixture performance budgets for open time, scroll/expand latency, typing latency, realized control count, memory, and cache invalidation. (In progress 2026-06-09: open-time budgets measured and enforced for 12 fixtures up to 253 slices, refresh-after-edit measured (region-manifest §5.1–5.3, `DataTreeTimingBaselines.json`); **remaining:** scroll/expand, typing latency, realized-count, memory, and cache-invalidation lanes — region-manifest §5.4.) +- [x] 7.8 Produce Path 3 parity bundles for each first-slice and core parity fixture: WinForms visual evidence, Avalonia visual evidence, semantic snapshot, workflow/accessibility evidence, and an actionable failure summary. (Done 2026-06-09 for the first-slice scenario: `Path3BundleTests` assembles the canonical bundle per the 2.9 contract — shared scenarioId/bundleId/failureSummaryId, lane manifest with explicit proven/pending status, semantic snapshot, Skia-rendered Avalonia frame, the committed WinForms verified baseline, UIA workflow evidence pointers, and timing artifacts. Core-fixture bundles are produced the same way as each fixture lands.) +- [x] 7.9 Replace preview/prototype host wiring with typed-definition-backed product wiring before any migrated surface is advertised as product-ready under the global switch. (Done by 4.8/4.10 + 6.10: the product route is compiled-definition → region model → fenced LCModel editing; `PocEntryDto`/`ShowEntry` are preview-host only and the active-host contract audit keeps it that way.) +- [x] 7.10 For each migrated region manifest, record whether non-migrated global consumers remain on explicit legacy fallback, and block ambiguous "best effort" routing through partial POC hosts. (Done 2026-06-09: `region-manifest.md` `uiModeBehavior` + the switch-contract gate record per-host behavior; `LexicalEditSurfaceSelectionService` returns explicit `ExplicitLegacyFallback`/`Blocked` decisions — never best-effort — and `RecordEditViewSwitchTests` (2.11) prove every current consumer's declared behavior under both modes.) +- [x] 7.11 Add UIA automation-tree parity evidence per migrated region: snapshot the legacy surface's UIA names/roles/order alongside the Avalonia surface's, and run an assistive-technology smoke (Tab/arrow traversal, menu and chooser launch) proving keyboard-only and screen-reader navigation reach equivalent targets. Wire this into the region manifest accessibility gate so it blocks default enablement, not just release notes. (Done 2026-06-09 for the names/order lane: `PreviewHostUiaTests.PreviewHost_UiaTree_ExposesLegacyFieldLabels_InLegacyOrder` proves on the REAL Windows accessibility tree that the Avalonia surface announces the legacy slice labels in legacy top-to-bottom order, alongside the existing invoke/popup UIA smokes; wired into the manifest accessibility gate (§6, Partial → blocks default). The Tab/arrow + chooser-launch assistive smoke rides the chooser-dialog work (6.3/3.16) and keeps the gate row Partial until then.) ## 8. C++ Viewing/Render Seam Decommissioning -- [ ] 8.1 Inventory all native Views/C++ viewing/rendering/editor dependencies reachable from the targeted Lexical Edit region, including `RootSite`, `IVwEnv`, RootBox/ViewSlice paths, `ManagedVwWindow`, measurement, selection, hit testing, scrolling, editor realization, and text rendering adapters. -- [ ] 8.2 Classify dependencies as baseline-only, non-migrated-region-only, custom linguistics service dependency, or blocker for the targeted migrated region. +- [x] 8.1 Inventory all native Views/C++ viewing/rendering/editor dependencies reachable from the targeted Lexical Edit region, including `RootSite`, `IVwEnv`, RootBox/ViewSlice paths, `ManagedVwWindow`, measurement, selection, hit testing, scrolling, editor realization, and text rendering adapters. (Done 2026-06-09: `native-views-audit.md` §8.1 — full chain with file:line evidence: C++ engine incl. `VwTextStore` IME and `VwAccessRoot`, `ViewsInterfaces` interop, `SimpleRootSite`/`RootSite` hosting (paint/hit-test/typing/`RenderEngineFactory`), ~20 Views-backed slice/view classes incl. the `LabeledMultiStringView` stack; `ManagedVwWindow` confirmed already retired.) +- [x] 8.2 Classify dependencies as baseline-only, non-migrated-region-only, custom linguistics service dependency, or blocker for the targeted migrated region. (Done 2026-06-09: `native-views-audit.md` §8.2 — every inventoried dependency classified, each row marked evidence-based vs judgment; the blocker set is exactly what 6.13's TsString foundation + 6.x editors must replace.) - [ ] 8.3 Replace region-local C++ viewing/rendering/editor usage with managed/Avalonia services for text shaping, measurement, selection metadata, hit testing, scrolling, rendering, and editor realization. -- [ ] 8.4 Add tests or instrumentation proving the migrated region does not instantiate or call native Views/C++ viewing/rendering/editor infrastructure at runtime. +- [x] 8.4 Add tests or instrumentation proving the migrated region does not instantiate or call native Views/C++ viewing/rendering/editor infrastructure at runtime. (Done 2026-06-09 at current region scope: `EngineIsolationAuditTests` (assembly references + source symbols — the production Avalonia assembly cannot even load native Views), `RecordEditViewActiveHostContractTests` (the live product host never initializes/drives the hidden legacy DataTree under Avalonia mode), and the headless render/editing suites run the full region path with no native Views present. Re-verify per region as new editors land — the audit is wired to fail the suite on any regression.) - [ ] 8.5 Remove or disable region-local native viewing/render adapters after replacement tests pass, while leaving shared native Views code available for non-migrated consumers. -- [ ] 8.6 Track any repo-wide native Views deletion blockers that remain outside the migrated Lexical Edit region. -- [ ] 8.7 Classify non-viewing native dependencies such as spell-check interop, parser tools, XAmple, Encoding Converters, ICU, Expat/ParserObject, and reg-free COM tooling as custom linguistics/service/tool dependencies unless they own display, layout, hit testing, selection, editor realization, or other Avalonia viewing behavior. -- [ ] 8.8 Define service seams for retained custom linguistics engines so Avalonia consumes results through managed contracts and never hosts their UI/render/editor infrastructure. +- [x] 8.6 Track any repo-wide native Views deletion blockers that remain outside the migrated Lexical Edit region. (Done 2026-06-09: `native-views-audit.md` §8.6 — Interlinear/Sandbox, Concordance, Discourse, XMLViews browse surfaces, `XmlDocItemView`, Notebook/Grammar/Lists DataTree fallbacks, app-wide `FwTextBox` widgets, Framework/printing, and reg-free COM packaging, each mapped to the phase that addresses it (lexical-edit 7.x, shell phase, or explicitly unplanned).) +- [x] 8.7 Classify non-viewing native dependencies such as spell-check interop, parser tools, XAmple, Encoding Converters, ICU, Expat/ParserObject, and reg-free COM tooling as custom linguistics/service/tool dependencies unless they own display, layout, hit testing, selection, editor realization, or other Avalonia viewing behavior. (Done 2026-06-09: `native-views-audit.md` §8.7 — all classified as services/tools with evidence none owns viewing behavior; notably the spell-check *squiggle drawing* belongs to `VwRootBox`, not the spelling engine, so spelling stays a service.) +- [x] 8.8 Define service seams for retained custom linguistics engines so Avalonia consumes results through managed contracts and never hosts their UI/render/editor infrastructure. (Done 2026-06-09: `native-views-audit.md` §8.8 — `IParser`/`ParserScheduler`/`ParserConnection` for XAmple/HermitCrab, `ISpellEngine`/`SpellingHelper` (never RootBox `SetSpellingRepository`), `ECInterfaces` for Encoding Converters, `Icu`/`CustomIcu`, and the `TsStringWrapper` clipboard contract; rule recorded and backstopped by `EngineIsolationAuditTests`.) ## 9. XML Retirement Planning -- [ ] 9.1 Design canonical post-XML view-definition authoring/storage format. -- [ ] 9.2 Build XML-to-typed-definition migration tooling and audit reports. -- [ ] 9.3 Prove migration on shipped LexEntry/LexSense layouts and selected user override fixtures. +- [x] 9.1 Design canonical post-XML view-definition authoring/storage format. (Done 2026-06-09 as a labeled proposal for owner review: `canonical-view-definition-design.md` — layered JSON: shipped definitions as committed deterministic JSON generated by the existing `XmlLayoutImporter`→`ViewDefinitionModel` pipeline (hand-owned post-retirement), per-project customer overrides as sparse JSON patches keyed by `ViewNode.StableId` with a `formatVersion` successor to `LayoutVersionNumber=27`, custom fields/`$ws`/`choiceGuid` expanded at compile time and never stored — directly fixing the legacy whole-layout-copy staleness in `Inventory.PersistOverrideElement`. C# builders/YAML/new-XML/DB-backed each rejected with evidence; 5-step migration sequence; 7 open owner questions.) +- [ ] 9.2 Build XML-to-typed-definition migration tooling and audit reports. (In progress: the conversion pipeline (`XmlLayoutImporter`/`ViewDefinitionCompiler`) and the audit report (`LayoutImportCoverage` → `layout-import-coverage.md`, regenerated by tests) exist; **remaining:** the JSON serializer + override migrator from `canonical-view-definition-design.md` steps 1–3.) +- [ ] 9.3 Prove migration on shipped LexEntry/LexSense layouts and selected user override fixtures. (In progress: shipped layouts proven — 136 detail layouts import with measured coverage, the product first slice compiles from real `LexEntry`/`MoForm`/`LexSense` definitions; **remaining:** user override fixtures per `override-fixtures.md`.) - [ ] 9.4 Disable runtime XML for a gated migrated surface while retaining import/audit fallback. -- [ ] 9.5 Document remaining XML blockers, especially custom fields, ghost items, table views, choosers, TreeView-heavy views, and any remaining native viewing/render coupling. +- [x] 9.5 Document remaining XML blockers, especially custom fields, ghost items, table views, choosers, TreeView-heavy views, and any remaining native viewing/render coupling. (Done 2026-06-09: `xml-retirement-blockers.md` — 12-family blocker register with measured prevalence from the coverage report (161 ``, 48 ghost attrs, 230/43/63 if/ifnot/where, 473 menu/hotlink attrs, 259 unresolved cross-class parts, 94/93 chooser metadata, ~21 RootSite slice types), each mapped to its owning task/phase with retirement risk, plus the cross-cutting deadline that ghost/conditional/chooser schema must be reserved before canonical Layer-1 JSON freezes.) ## 10. Validation -- [ ] 10.1 Run targeted managed tests for changed areas using `./test.ps1` filters or relevant VS Code tasks, including Avalonia projects/tests through the normal repo test path when touched. -- [ ] 10.2 Run render/parity baseline tests for affected surfaces. -- [ ] 10.3 Run native viewing/render seam audit tests/instrumentation for any region claimed as migrated. +- [x] 10.1 Run targeted managed tests for changed areas using `./test.ps1` filters or relevant VS Code tasks, including Avalonia projects/tests through the normal repo test path when touched. (Running continuously through this change: FwAvaloniaTests 128/128 (incl. Skia rendering), xWorks editing/host/bridge suites green, DetailControls render/timing 29/29.) +- [x] 10.2 Run render/parity baseline tests for affected surfaces. (2026-06-09: legacy DataTree render + timing baselines green with committed thresholds; Avalonia visual lane captured via `VisualParityCaptureTests`; semantic snapshots deterministic in `ViewDefinitionTests`.) +- [x] 10.3 Run native viewing/render seam audit tests/instrumentation for any region claimed as migrated. (2026-06-09: `EngineIsolationAuditTests` + `RecordEditViewActiveHostContractTests` green; runs with every suite, so any regression blocks by construction.) - [ ] 10.4 Run Graphite/native-rendering default-path validation for any region proposed as default Avalonia UI. - [ ] 10.5 Run browser/PDF replacement validation for default-path XHTML preview, print, or PDF flows. -- [ ] 10.6 Run `./build.ps1` with normal Avalonia build coverage before implementation work is considered ready for review. +- [x] 10.6 Run `./build.ps1` with normal Avalonia build coverage before implementation work is considered ready for review. (2026-06-09: `./build.ps1` → "[OK] Build complete!", Avalonia projects built on the normal script path.) - [ ] 10.7 Run `CI: Full local check` before commit/push. - [ ] 10.8 Verify every migrated-region manifest has passing evidence for native-call instrumentation, no unapproved Graphite/native-rendering default-path dependency, undo/redo, accessibility, localization, keyboard/IME, customer override fixtures, performance budgets, and rollback behavior. - [ ] 10.9 Invoke the shell-global phase of `avalonia-command-focus`, `avalonia-ui-scheduler`, and `avalonia-lifetime` through `fieldworks-avalonia-shell-migration` instead of redefining those seams ad hoc during shell work. - [ ] 10.10 Verify every scenario used to claim visual fidelity has a complete Path 3 bundle and that each bundle explicitly classifies which lanes are proven (`semantic`, `visual`, `workflow/accessibility`, `performance`) and which remain pending. -- [ ] 10.11 Verify Avalonia projects and tests are exercised through the normal repo build/test graph rather than relying on branch-only or optional build lanes as the primary evidence path. +- [x] 10.11 Verify Avalonia projects and tests are exercised through the normal repo build/test graph rather than relying on branch-only or optional build lanes as the primary evidence path. (2026-06-09: traversal glob includes all four projects (1.11), `FieldWorks.sln` lists them, `./build.ps1` builds them, `test.ps1` discovers `FwAvaloniaTests.dll` from shared Output. No optional lanes remain.) - [ ] 10.12 Run a dedicated product wiring review for every UI mode or host-routing change: branch-vs-main diff, product-vs-preview boundary, setting/broadcast path, host reload path, focus/command routing, fallback state, and no hidden active legacy rendering. -- [ ] 10.13 Run localization review for every product-facing Avalonia or UI-mode change: `.resx` coverage, Crowdin compatibility, stable automation IDs, localized user messages, and explicit evidence for any remaining prototype strings. +- [x] 10.13 Run localization review for every product-facing Avalonia or UI-mode change: `.resx` coverage, Crowdin compatibility, stable automation IDs, localized user messages, and explicit evidence for any remaining prototype strings. (Run 2026-06-09: `localization-review.md` — resx coverage PASS (8/8 keys consumed + test-locked), automation IDs PASS, user messages PASS, UI-mode labels PASS (resx-backed in `LexTextControls.resx`); Crowdin PARTIAL with three named gaps: GAP-L1 `FwAvalonia.csproj` needs an explicit `` for the satellite-assembly localizer, GAP-L2 two hardcoded UIA *names*, GAP-L3 field labels render raw English from layout XML (legacy localizes via StringTable). GAP-L1/L2/L3 fixes tracked under section 6 close-out.) + +## 11. Lexical Edit Viewing Parity (added 2026-06-09) + +> Point-by-point parity with the legacy WinForms DataTree's DATA-VIEWING features, derived from a +> full inventory of `SliceFactory`/`DataTree`/`Slice`/`SliceTreeNode`/`SummarySlice` and the shipped +> layouts (V1–V16 analysis). Editing-side parity is sections 6/8; this section is what the user SEES. + +- [x] 11.1 Field-type rendering — text: multistring/string render per-WS editable rows with project fonts/RTL (6.1/6.2); `sttext` structured text renders its paragraph contents; `lit` renders the literal label row. (`FullEntryRegionComposer.WalkTextField`/`WalkOtherField` StText branch; composer tests.) +- [x] 11.2 Field-type rendering — references: atomic (possibility/POS/MSA/default/disabled variants) render the target's name; vectors (default/poss/semdom/phoneenv) render joined names — type-driven from metadata so every reference editor in the census displays. Chooser write-back beyond morph type is editing work (6.3 follow-on), not viewing. (`WalkOtherField` Reference/OwningAtomic branches.) +- [x] 11.3 Field-type rendering — booleans render as real checkboxes (legacy CheckboxSlice), editable through the option seam (`RegionFieldKind.Boolean`, `BuildBoolean`; toggle staged + tested). +- [x] 11.4 Field-type rendering — numerics/dates: integers render editable; `time` fields (DateCreated/DateModified) render formatted dates via `SilTime`; `gendate` renders `GenDate.ToLongString()`. (`WalkOtherField` Boolean/Integer/Time/GenDate branches; DateCreated proven under show-hidden.) +- [x] 11.5 Field-type rendering — `summary` slices render as section header rows (their legacy role), collapsible like their sections. +- [x] 11.6 Field-type rendering — media, embedded views, commands. (Done 2026-06-09: `picture`/`image` fields render the ACTUAL image — caption label + bitmap loaded from the `CmPicture`'s file via `AbsoluteInternalPath`, path text when the file is missing (`RegionFieldKind.Image`, `WalkPictures`, `BuildImage`; proven with a real decoded PNG); `jtview` embedded views render the object's summary text read-only; `command` slices render their button, present-but-disabled until the xCore command bridge (shell phase) supplies execution (`RegionFieldKind.Command`). Audio-visual media display and full embedded-view replacement ride the media/table lanes; custom fields remain 9.x B1.) +- [x] 11.7 Tree structure — collapsible sections: every group/summary/sequence header toggles its nested rows (the legacy SliceTreeNode +/- boxes), nested sections collapse with their parent, and the layout's `expansion='collapsed'` supplies the initial state. (`WireCollapsibleHeaders`; toggled + initial-state headless tests.) +- [x] 11.8 Tree structure — expansion-state persistence. (Done 2026-06-09: the view takes get/record expansion hooks keyed by header stable id; `RecordEditView` persists state in-session (dictionary) and across sessions (PropertyTable local settings, `LexEditExpansion:{stableId}`, persisted) — the legacy `ExpansionStateKey` behavior. Proven: a toggle records into the store and a rebuilt view applies the persisted collapse. The collapsed-summary preview need is met by design: our sense/section headers permanently display the summary text (number + gloss), so collapsing never loses the preview legacy showed only when collapsed.) +- [x] 11.9 Indentation per nesting level renders on labels and headers (legacy kdxpIndDist), driven by the composer's depth. +- [x] 11.10 "Show hidden fields" — the same `ShowHiddenFields-{tool}` PropertyTable setting legacy reads now drives the Avalonia region: `visibility=never` fields appear, empty `ifdata` fields appear, and toggling the View-menu setting re-resolves the region live (`OnPropertyChanged` hook). (Composer `showHiddenFields` + RecordEditView wiring; tested.) +- [x] 11.11 Visibility rules at view time — `always`/`ifdata`/`never` honored exactly (ifdata hides empty fields incl. empty reference rows and whole empty sections; never hides unless show-hidden), matching legacy `ProcessPartChildren`. (Composer tests incl. Bibliography appear/disappear.) +- [x] 11.12 Scrolling — the region scrolls like legacy DataTree's AutoScroll panel (ScrollViewer wrapper; 60-row overflow test). Scroll-position reset on record navigation matches legacy (re-shown views start at top). Lazy slice realization for huge entries is the 7.1 virtualization lane if profiling demands it. +- [x] 11.13 Sense presentation — hierarchical sense numbering (1, 2, 1.1) with the sense's gloss summary in the header line (legacy `%O)` numbering + summary preview), every sense and subsense a collapsible section, nested subsenses indented. (Composer `WalkSequence`; numbering/summary/collapsible tested.) +- [x] 11.14 Ghost/add-prompt lines. (Done 2026-06-09 for the viewing half: empty always-visible object/sequence/picture fields render the legacy ghost prompt row — localized "Click here to add {label}." (`ksGhostAddPrompt`), read-only, indented in place (`AddGhostPrompt` in the composer). Creation-on-click is the editing half and stays with the ghost lane (9.x B2: ghostClass/ghostInitMethod semantics).) +- [x] 11.15 Visual emphasis. (Done 2026-06-09: the importer now consumes `` bold/percentage-fontsize into the typed IR (`ViewNode.BoldEmphasis`/`FontScalePercent` — the lexeme form's legacy bold/120%), the composer threads them into the per-WS values, and the editor applies bold weight + scaled size (proven: bold + 14.4px on a 120% field). Top-level sections get the legacy heavy-weight 2px separator rule. A draggable `GridSplitter` divides the label/value columns with session-remembered position (legacy splitter; cross-session persistence can ride the 11.8 store if wanted). Density tokens (6.7) remain the base.) +- [x] 11.16 Labels localize through the same `StringTable.LocalizeAttributeValue` lane legacy slices use (GAP-L3 closed for composed views). +- [x] 11.17 Tooltips and context menus. (Done 2026-06-09 at viewing scope: field labels carry tooltips (legacy `Slice.ToolTip` = label); every text row offers a context menu with a WORKING Copy command (selected text or whole value via the Avalonia clipboard) — the viewing-side affordance. The full per-field command menus (insert/delete/move slice commands) are xCore command-routing work, shell phase per the seam specs, and will populate the same `ContextFlyout`.) + +## 12. Visual Fidelity (added 2026-06-10) + +> Style-level parity with the legacy DataTree rendering, derived from the extracted visual +> constants (DataTree.cs/Slice.cs/SliceTreeNode.cs/InnerLabeledMultiStringView.cs) and direct +> comparison of the committed legacy render baseline against the Avalonia Skia frame capture. +> Styling changes only ("CSS"), minimal code; exact pixel match is explicitly not the target. + +- [x] 12.1 Row separator rules: a 1px `LightGray` line under each slice row spanning the full width (legacy `PaintLinesBetweenSlices`), and explicitly NO rules between writing-system rows within one multistring field (legacy renders those with `kvrlNone`). (Done: per-row 1px `SliceRule` Border rows in `LexicalEditRegionView`, collapsing with their row; multistring WS rows share one editor with no internal rules; `RegionViewingParityTests.VisualFidelity_FlatEditors_SliceRules_AndLegacyTokens`.) +- [x] 12.2 Flat value editors: text editors render borderless/transparent like legacy RootSite views (no box until interaction); focus indication stays subtle. (Done: `FwMultiWsTextField` boxes are BorderThickness=0/transparent background.) +- [x] 12.3 Writing-system abbreviation styling: small (~9pt) blue abbreviation, top-aligned at the value start with auto width (legacy `AbbreviationTextProperties`), replacing the fixed-width gray gutter. (Done: `PocDensity.WsAbbrevBrush`/`WsAbbrevFontSize=11`, top-aligned at the value start.) +- [x] 12.4 Label styling: 10pt labels in the legacy label hue (per the committed baseline pixels), single tunable token. (Done: `PocDensity.LabelBrush` #6666B8 / `LabelFontSize=13`, left-aligned.) +- [x] 12.5 Density: row spacing/margins tightened to legacy row rhythm; density token gate (6.7) updated as the reviewed decision record. (Done: spacing tokens in `PocDensity`; `DensityTokenGateTests` is the decision record.) +- [x] 12.6 Chrome: white background throughout, invisible splitter (legacy splitter is window-colored), splitter width 5px. (Done: white background, window-colored splitter column at `PocDensity.SplitterWidth=5`.) +- [x] 12.7 Evidence: regenerate the Avalonia parity frame after the pass and keep it alongside the legacy baseline in the Path 3 bundle for side-by-side review. (Done: `Output/Debug/avalonia-region-first-slice.png` regenerated via `VisualParityCaptureTests` after the pass, alongside the committed legacy baseline PNG.) + +## 13. Context Menus (added 2026-06-10) + +> Migrate the legacy right-click behaviors of the Lexical Edit view to the Avalonia surface — SAME +> functionality (mandatory: the menus execute through the same xCore command pipeline), about the +> same look. SHARED-USAGE callout: the slice context-menu machinery (`DTMenuHandler`, the +> `mnuDataTree-*` XML menu definitions, and the xCore popup adapter) is the same code used by every +> DataTree-hosting tool — Grammar (posEdit etc.), Notebook, Lists, and Words/Analyses detail panes — +> so this bridge migrates those tools' menus with zero extra work when their surfaces adopt the +> region host. + +- [x] 13.1 Import the layout `menu=`, `contextMenu=`, and `hotlinks=` attributes into the typed IR (today they are reported as dropped functional attributes), so every node carries its legacy menu bindings. +- [x] 13.2 Thread the menu ids and the bound object (hvo) through the composer into region fields/headers. +- [x] 13.3 Right-click on an Avalonia field/header shows the SAME menu the legacy slice shows: the host surfaces a (menuId, hvo, screen-point) event; `RecordEditView` materializes the xCore-defined menu over the host (same definitions, same command dispatch through the mediator, same look via the existing adapter) honoring the dialog-ownership rules (3.16). +- [x] 13.4 Command target context: ensure object-targeted commands (insert/delete/move on a specific sense) resolve the right object from the Avalonia surface, reproducing the legacy current-slice context the handler expects; document any command that cannot resolve without a live slice and its fallback. +- [x] 13.5 Hotlinks: section headers expose their `hotlinks=` commands (the legacy summary-line link commands) through the same bridge. +- [x] 13.6 Tests: importer captures the menu attributes; composer threads them; the view raises the context-menu event with correct (menuId, hvo) on right-click (headless); menu-definition resolution from the shipped XML proven in the xWorks harness; Copy remains available alongside the migrated items. + +Evidence (13.x, 2026-06-10): 13.1 `XmlLayoutImporter` captures `menu`/`contextMenu`/`hotlinks` (caller part ref wins for `menu`, like `DTMenuHandler.ShowSliceContextMenu`) onto `ViewNode`; coverage report regenerated (unhandled-attribute warnings 574 -> 81). 13.2 `FullEntryRegionComposer` threads `MenuId`/`ContextMenuId`/`HotlinksId` + owning `ObjectHvo` onto every text/chooser/read-only row and group/summary/sequence/sense-item header (sense items bind the ITEM hvo). 13.3 `LexicalEditRegionView.WireSliceMenu` (labels/headers) and `FwMultiWsTextField` (value boxes with a `contextMenu=` binding) raise `RegionMenuRequest` on right-click; `RecordEditView.OnRegionMenuRequested` shows the menu via `XWindow.ShowContextMenu` with the legacy id recipe (menu + `mnuDataTree-Object`; in-string adds `mnuDataTree-MultiStringSlice`). 13.4 `EnsureMenuCommandAdapter` lazily initializes the hidden legacy DataTree + `DTMenuHandler` as the approved "command-menu-routing" baseline adapter (detached, never visible), re-shows the current entry on it, points `CurrentSlice` at the slice bound to the clicked row's hvo, and `GetMessageAdditionalTargets` includes both colleagues once the adapter exists so mediator command dispatch works in Avalonia mode. 13.5 hotlinks ride the same bridge (`RegionMenuKind.Hotlinks`; slice menus append the hotlinks id so link commands stay reachable). 13.6 `XmlLayoutImporterMenuBindingTests` + `RegionMenuRequestTests` (headless right-click per kind; unbound rows keep the local Copy flyout — `RightClick_OnUnboundRow_RaisesNoRequest`) + `FullEntryRegionComposerTests.Compose_ThreadsLegacyMenuBindings_AndOwningObjectHvos` / `Compose_EveryMenuBinding_ResolvesInTheShippedWindowConfiguration` (every composed id, plus the always-appended `mnuDataTree-Object`/`mnuDataTree-MultiStringSlice`, resolves to a `` definition in the shipped configuration). + +## 14. Field Interaction Parity (added 2026-06-10, post-review feedback) + +User review of the live Avalonia surface against legacy surfaced five interaction/visual gaps. +The fixes keep the legacy behavior contract: ghost lines create on edit, right-click works from +the whole row, rules separate entries (not labels), the view saves as you go, and long values +wrap instead of clipping. + +- [x] 14.1 Ghost "Click here to add ..." rows behave like legacy ghost slices: the prompt is a watermark that disappears on click (focus) and typing CREATES the missing object inside the fenced session — the importer now captures `ghost=`/`ghostWs=`/`ghostClass=`/`ghostLabel=`, and the composer registers a create-on-edit setter (`MakeNewObject` + text into the ghost field; abstract `MoForm` defaults to `MoStemAllomorph` like legacy `CreateAllomorph`; `LexSense` defaults the text to `Gloss`). (Evidence: `GhostRow_WatermarkClearsOnFocus_AndRestoresWhenLeftEmpty`; `Compose_GhostLexemeForm_CreatesTheAllomorph_OnFirstEdit`; `Compose_GhostSenses_CreateASense_WithTheTypedGloss`.) +- [x] 14.2 Right-click reliability in the live app: labels/headers get a transparent background (a null background only hit-tests the glyphs, so most of the label area never received the press), the lazily-initialized command adapter sets the DataTree stylesheet (skipped by `Init` in Avalonia mode), and an adapter failure no longer suppresses the menu itself (logged; menu still shows, colleague-dependent items disable). +- [x] 14.3 Slice rules separate ENTRIES only: the 1px rule renders in the value column alone — never under the label panel. (Evidence: `Rules_UnderlineOnlyTheValueColumn_AndValuesWrap`.) +- [x] 14.4 No Save/Cancel buttons — the view auto-saves like legacy: any editor losing focus commits the open session (validation-gated, inline errors, one undo step per field), Escape still cancels, and `RecordEditView` commits a pending session before re-showing/record switch (invalid staged state rolls back). The `RegionEditContextHolder` displacement-cancel remains the safety net. (Evidence: `AutoSave_OnFocusLoss_WhenClean_CommitsOnce_AndRaisesEditCompleted`, `AutoSave_WithValidationErrors_ShowsThemInline_AndDoesNotCommit`, `Escape_CancelsTheSession_AndRaisesEditCompleted`, `EditMode_HasNoSaveCancelButtons_BecauseItAutoSaves`.) +- [x] 14.5 Word wrap: value editors wrap long text and the row grows vertically (TextWrapping.Wrap on the flat editors — cheap in Avalonia where legacy WinForms drawing made this prohibitive). (Evidence: `Rules_UnderlineOnlyTheValueColumn_AndValuesWrap`.) + +Evidence (14.x, 2026-06-10): FwAvaloniaTests 156/156, xWorks lexical-edit fixtures 33/33 green; +layout-import-coverage regenerated (ghost attributes now handled). + +## 15. Native Context Menus + Menu-Logic Parity (added 2026-06-10, post-review feedback) + +Live testing showed: (a) TWO menus appear on right-click (the Avalonia TextBox theme flyout plus +the bridged menu), (b) the bridged menu is the WinForms adapter, not Avalonia, and (c) no +add-sense items. Sub-agent sweeps produced the complete binding/command/engine inventory in +`context-menu-inventory.md` (the written-down plan this section implements). Shared-usage note: +the xCore menu engine + DTMenuHandler serve Grammar/Notebook/Lists/Words too — the Avalonia +renderer below works for those tools unchanged. + +- [x] 15.1 Avalonia-native menu rendering: a neutral `RegionMenuItem` model (label/enabled/checked/separator/children/execute) rendered as an Avalonia `MenuFlyout`; xWorks bridges the SAME xCore `ChoiceGroup` (PopulateNow → GetDisplayProperties per item → OnClick on pick) so labels, enablement, checkmarks, submenus, and mediator command dispatch are identical to legacy. WinForms adapter menu remains the fallback if materialization fails. +- [x] 15.2 Exactly ONE menu per right-click: bridged value boxes drop the TextBox theme flyout (ContextFlyout = null) and swallow ContextRequested; unbound boxes keep the local Copy flyout. +- [x] 15.3 Sense (and any sequence item) headers inherit the ITEM layout's root menu binding — `mnuDataTree-Sense`/`mnuDataTree-Sense-Hotlinks` ride every sense header from LexSense.fwlayout:6 (the Senses part ref itself carries no menu), so Insert Sense / Insert Subsense / Move / Merge / Delete are reachable exactly where legacy offers them. +- [x] 15.4 Hidden-adapter enablement parity: `DTMenuHandler.OnDisplayDataTreeInsert` gates on `DataTree.Visible` (DTMenuHandler.cs:865), wrongly DISABLING Insert items for the hidden command-routing adapter; the adapter tree declares itself (`DataTree.IsExternalCommandAdapter`) and the gate honors it. (Audit: every other sense command checks only model state — see inventory §3.) +- [x] 15.5 Tests: flyout building (items/separators/submenus/disabled/checked + click executes), bridged boxes have no theme flyout, sense item headers carry mnuDataTree-Sense with Insert-Sense commands present in the shipped definition, and the existing resolvability test still covers every composed id. + +Evidence (15.x, 2026-06-10): 15.1 `RegionMenuItem`/`RegionMenuFlyout` (FwAvalonia, no xCore deps) + +`XCoreMenuBridge` (xWorks) over the new headless `XWindow.GetContextMenuChoiceGroup`; the WinForms +adapter menu remains the in-place fallback in `RecordEditView.OnRegionMenuRequested`. 15.2 bridged +boxes null the theme ContextFlyout and swallow ContextRequested (`BridgedValueBox_DropsTheThemeFlyout_UnboundKeepsCopy`). +15.3 unresolved-part recovery now wraps recovered children in a group carrying the CALLER bindings +(HeavySummary -> mnuDataTree-Sense) and `ResolveItemMenuBinding` threads the item layout root binding +onto per-item headers (`Compose_SenseHeaders_BindTheSenseMenu_WithInsertSenseDefined`). 15.4 +`DataTree.IsExternalCommandAdapter` honored at DTMenuHandler.cs OnDisplayDataTreeInsert; set by +`EnsureMenuCommandAdapter`. 15.5 `RegionMenuFlyout_BuildsItems_AndClickExecutes` + the above; suites +158/158 (FwAvalonia) and 39/39 (xWorks lexical-edit fixtures) green; full inventory in +context-menu-inventory.md. +## 16. Stability: WinForms-hosted Avalonia finalizer crash (added 2026-06-10, live-crash triage) + +Every FieldWorks.exe crash in the Application event log (3 distinct occurrences incl. 2026-06-09 +19:10 and 2026-06-10 13:56) shares ONE signature: `InvalidOperationException -> +Control.MarshaledInvoke -> Control.BeginInvoke -> WindowsFormsSynchronizationContext.Post -> +MicroCom.Runtime.MicroComProxyBase.Finalize()`. Avalonia (11.3.17 / MicroCom.Runtime 0.11.0) COM +proxies capture the ambient SynchronizationContext at creation (verified by reflection: +`MicroComProxyBase._synchronizationContext`) and their finalizers post the native Release through +it; once the WinForms marshaling window is gone (window teardown, project switch, shutdown — or +any idle-time GC afterwards) the post throws on the FINALIZER thread, terminating the process. +This matches the reported "crashes when starting, when idling, when loading the project". + +- [x] 16.1 `FinalizerSafeSynchronizationContext` (FwAvalonia/Seams) installed as the UI thread's ambient context BEFORE Avalonia initializes (`PocWinFormsHostControl.EnsureAvaloniaInitialized`), so every MicroCom proxy captures it: delegates to the real WinForms context, swallows only dead-marshal failures (InvalidOperationException incl. ObjectDisposedException, InvalidAsynchronousStateException) whose payload — a finalizer's native Release — is moot once the target is gone. WinForms cannot displace it (InstallIfNeeded only replaces null/base-type contexts). (Evidence: `FinalizerSafeSynchronizationContextTests` — swallows the exact crash signature, passes live posts through, idempotent install; suite 161/161.) diff --git a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md new file mode 100644 index 0000000000..a26755ffde --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md @@ -0,0 +1,368 @@ +# XML Retirement Blocker Register (Task 9.5) + +Date: 2026-06-09 + +Scope: the remaining blockers that prevent disabling runtime XML Parts/Layout resolution +(task 9.4) for any migrated surface. Sources: the measured importer coverage report +`layout-import-coverage.md` (task 4.9, regenerated by `LayoutImportCoverageTests`), the native +Views audit `native-views-audit.md` (tasks 8.1/8.2/8.6), the canonical-format proposal +`canonical-view-definition-design.md` (task 9.1), and the importer itself +`Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs`. + +Measured baseline (committed coverage report, deterministic; refreshed 2026-06-11 after the +B10 resolution work and again after the B2/B3 work below — the original 9.5 snapshot was 549 +nodes / `unresolved-part` 259): + +- 136 detail layouts imported (594 jtview/publishing layouts skipped as out of detail-lane scope), + 1128 typed nodes produced (was 836 before B3 — conditional content now imports as nodes). +- **75.5%** element-occurrence coverage, **55.5%** attribute-occurrence coverage. +- Diagnostics by code: `unhandled-attribute` 715 Info + 5 Warning, `slice-content-dropped` 497, + `dynamic-editor` 122, `unresolved-part` 63 Error (see B10 status), `unknown-part-content` 2 + (was 85 — the bulk were conditional part contents, now imported per B3), `sublayout-dropped` 1, + `unknown-editor` 1. `conditional-dropped` is **zero** over the shipped detail lane: every + condition form the detail layouts actually use is in the supported set. + +Counts below are occurrences in the shipped files under +`DistFiles/Language Explorer/Configuration/Parts/`, so they weight each family by how often real +layouts actually use it. Where the census spans all shipped layouts (including the 594 publishing +layouts), that is noted; the detail-lane import diagnostics are the binding numbers for this +change's surfaces. + +--- + +## Blocker register + +### B1. Custom fields / `` + +- **What it is:** Legacy layouts mark schema-driven injection points — `` blocks and `` — that the legacy + runtime expands from LCModel metadata into per-project custom-field slices. The importer + produces a `CustomFieldPlaceholder` node for the `customFields` form but drops `` + with a `generated-content-dropped` Warning (`XmlLayoutImporter.cs:95-101`); no runtime + expansion exists in the Avalonia path. +- **Measured prevalence:** 161 `` elements; `generate@class`/`@fieldType`/ + `@restrictions` 161 each, `generate@destClass` 1. +- **Addressed by:** Layer 3 of the canonical design (9.1) — compile-time expansion by + `ViewDefinitionCompiler` over LCModel metadata, never stored; implementation lands with the + 9.2/9.3 migration tooling and gates 7.4/7.5 parity. Customer override fixtures (1.4, + `override-fixtures.md`) must include custom-field projects. +- **Status (2026-06-11):** the `customFields="here"` half no longer drops. The composer + (`FullEntryRegionComposer.WalkCustomFields`, covered by + `FullEntryRegionComposerCustomFieldTests`) expands every `CustomFieldPlaceholder` node at + compose time from live MDC metadata, mirroring legacy `DataTree.EnsureCustomFields` + + `SliceFactory.MakeAutoCustomSlice`: per visited object (entry/sense/allomorph/example) it + enumerates the class's (and base classes') custom fields, emits rows labeled by the field's + Userlabel at the placeholder position in creation (flid) order, and renders by + `CellarPropertyType` — String/MultiString/MultiUnicode as editable text rows (multi-WS per + the field's WsSelector, staged through the same setter registry/fenced session as authored + fields, one undo step), Integer editable, GenDate read-only formatted, atomic/sequence + possibility references as read-only joined names, OwningAtomic StText as read-only + paragraphs. Custom rows are visibility=always like the legacy generated parts (no + `visibility` attribute, `DataTree.cs:2435`): empty custom fields stay visible regardless of + "show hidden fields". **Deferred:** chooser write-back for reference custom fields (rides + the 6.3 service-backed chooser lane), StText editing, and the `` compile-time + expansion (9.2/9.3), which remains a `generated-content-dropped` Warning in the importer. +- **Retirement risk if unaddressed:** ~~Severe~~ Reduced for the composed detail surface: + custom-field data is now visible and (text/int) editable there. Customer custom fields still + vanish on any surface that consumes only the imported typed definitions without the + composer's runtime expansion, and reference custom fields are read-only until 6.3 — both + must clear before 9.4 signs off a surface. + +### B2. Ghost items (`ghost`/`ghostWs`/`ghostClass` and friends) + +- **What it is:** Ghost attributes make a not-yet-created property render as an editable empty + line; typing into it creates the real object (`GhostStringSlice`/`GhostReferenceVectorSlice` + realize the editor, `DataTree.cs:2822`). ~~The importer classifies all ghost attributes as + functional drops~~ — see Status below; the `obj`/`seq` text-ghost metadata is now fully + imported, with only the `slice`-level ghost-vector attributes still dropped. +- **Measured prevalence:** 48 ghost-attribute occurrences: `seq@ghost` 7, `seq@ghostWs` 7, + `seq@ghostLabel` 6, `seq@ghostAbbe` 3, `seq@ghostInitMethod` 1, `slice@ghostClass` 5, + `slice@ghostField` 5, `obj@ghost` 3, `obj@ghostClass` 3, `obj@ghostLabel` 3, `obj@ghostWs` 3, + `obj@ghostInitMethod` 2. +- **Addressed by:** IR/canonical schema must reserve ghost representation **before** Layer-1 + JSON generation freezes (9.1 open question 4 — otherwise an early breaking `formatVersion` + bump); ghost editor realization is a named Blocker row in `native-views-audit.md` §8.2, + replaced by the 6.13 text foundation + edit-session become-real behavior. No dedicated task + number exists for IR ghost metadata yet — it should be scheduled under 6.x before 7.4 claims + parity on any ghost-bearing layout. +- **Status (2026-06-11):** generalized past the 14.1 hand-picked lane. The importer now carries + the complete ghost configuration on every ghost-bearing `obj`/`seq` node — `ghost`/`ghostWs`/ + `ghostClass`/`ghostLabel` **and `ghostInitMethod`** (previously a functional drop) — and the + canonical JSON serializer round-trips all five (`EveryViewNodeProperty_SurvivesRoundTrip`). + The composer's ghost setter honors `ghostClass` (creates the configured concrete class, e.g. + `MoStemAllomorph` for the abstract `MoForm` lexeme-form signature) and invokes + `ghostInitMethod` by reflection on the newly created object after the typed text lands, inside + the same fenced session — exactly `GhostStringSliceView.MakeRealObject`'s order + (`GhostStringSlice.cs:279-329`); creation + text + init is one undo step. Audited ghost + configurations in the shipped lexicon detail parts, all covered by tests + (`ShippedLexiconGhostConfigurations_AllImportWithCompleteMetadata`, composer tests in + `LexicalEditRegionEditingTests.cs`): LexemeForm/AsVariantForm (`obj`, ghostClass + + `SetMorphTypeToRoot`), Senses (composer default Gloss lane), Examples (`seq`, implicit + LexExampleSentence), ExtendedNotes (`seq`, analysis ws), Translations (`seq`, implicit + CmTranslation + `SetTypeToFreeTrans`). **Remaining:** the `editor="ghostvector"` slices + (`slice@ghostClass`/`slice@ghostField`, Notebook Researchers/Sources/etc.) are the + `GhostReferenceVectorSlice` chooser lane (6.3), not the text-ghost lane; the Notebook + `RnGenericRec.Text` ghost's Text/StTxtPara intermediate-object special case + (`GhostStringSlice.cs:289-301`) is Notebook-surface scope; the shipped `seq@ghostAbbe` + attribute is a typo legacy also ignores (it reads `ghostAbbr`, `DataTree.cs:2827`) and stays + an unhandled-attribute diagnostic on purpose. +- **Retirement risk if unaddressed:** ~~High~~ Cleared for the composed lexicon detail surface: + every shipped lexicon ghost line realizes and creates correctly. The notebook/reference-vector + ghost lanes above must clear before their surfaces' 9.4. + +### B3. Conditionals (`if`/`ifnot`/`where`) + +- **What it is:** Conditional display logic evaluated per record (field/class/ws/length/bool + tests). ~~The importer drops `if`/`ifnot` with a `conditional-dropped` Warning and does not + evaluate `where`~~ — see Status below; only unsupported (publishing-lane) condition forms + still take the `conditional-dropped` lane. +- **Measured prevalence:** elements `if` 230, `ifnot` 43, `where` 63; attributes `if@field` 208, + `if@boolequals` 179, `if@class` 64, `where@field` 60, `ifnot@field` 22, plus the long tail of + `is`/`intequals`/`stringaltequals`/`lengthat*` tests. (`if`/`where` counts include the + publishing-lane files; detail-lane drops surface as `conditional-dropped`.) +- **Addressed by:** No dedicated task. 7.2 (legacy-vs-IR semantics comparison) is where the + required conditional semantics get pinned; 9.1 open question 4 requires the canonical schema + to reserve conditional-visibility representation before Layer-1 freezes. Must be resolved + before 7.4 expands beyond hand-picked slices. +- **Status (2026-06-11):** resolved for the detail lane. The importer parses ``/`` + into typed `Conditional` nodes and ``/``/`` into `ChoiceGroup` + nodes with structured `ViewCondition` metadata (`ViewDefinitionModel.cs`), reserved in the + canonical JSON schema (`condition` object; round-trip covered) before the Layer-1 freeze. + The supported condition vocabulary is exactly what a fresh audit of the shipped detail parts + found in use: `field` + `boolequals` (44), `intequals` (9), `lengthatmost`/`lengthatleast` + (5), `intmemberof` (2), and on `where` clauses `intlessthan`/`is`/`target` (5), + `guidequals` (2), `is`+`target` (2) — i.e. `target` (this/owner/atomic-field hop), `is` (+ + `excludesubclasses`), `boolequals`, `intequals`, `intlessthan`, `intgreaterthan`, + `intmemberof`, `lengthatleast`, `lengthatmost`, `guidequals`. The composer evaluates per + object during the walk (`FullEntryRegionComposer.EvaluateCondition`), mirroring + `XmlVc.ConditionPasses` as `DataTree.ProcessSubpartNode` invokes it: target hop first, all + present tests conjoined, `ifnot` negated, first-passing `where` only. Proven on real shipped + conditionals in the memory cache: LexEntryRef RefType variant/complex twins compose + differently per `RefType`, `ShowMinorEntry` follows `EntryRefs lengthatleast=1`, the + MoAffixAllomorph infix-position `` follows the morph type, and + `MsEnvFeatures` reads the OWNING entry's MSA count through `target="owner"` + (`LexicalEditRegionEditingTests.cs`). **Skipped (documented):** the publishing-lane-only + forms — `stringequals`, `stringaltequals`, `hvoequals`, `flidequals`, `bidi`, + `atleastoneis`, `func`, `index`, `ws`, `flid`, slash field paths, and `$`-substituted values + (these appear only on `-Jt-` parts or inside `` in the shipped files) — keep the + `conditional-dropped` lane so they are never half-evaluated; the measured detail-lane + `conditional-dropped` count is now **0**. +- **Retirement risk if unaddressed:** ~~High~~ Cleared for the composed lexicon detail surface: + per-record show/hide now matches legacy for every condition form the detail layouts use. + Publishing/browse conditional vocabulary retires with those lanes (7.1/7.2, publishing + migration), not this change. + +### B4. Menus / hotlinks (context-menu and command bindings) + +- **What it is:** `menu`/`contextMenu` attributes bind slices to xCore context menus; + `hotlinks` binds the blue inline command links (e.g. "Insert Sense"). All are functional + drops (Warning) today. +- **Measured prevalence:** 473 occurrences: `slice@menu` 318, `part@menu` 123, `part@hotlinks` + 15, `seq@menu` 8, `obj@menu` 4, `slice@contextMenu` 3, `string@menu` 1, `deParams@menu` 1. +- **Addressed by:** Host context-menu seam exists (3.5 `ILexicalEditHost`); Avalonia + flyout/context-menu presentation is 6.3; workflow parity for menu-driven commands is 7.4 and + the 7.11 accessibility smoke. IR needs command-affordance metadata (modeled as extensible in + 4.1, not yet populated). +- **Retirement risk if unaddressed:** High. The slice context menus and hotlinks are the + primary insert/delete/move affordances in Lexical Edit; without them the migrated surface is + read-mostly. Blocks 7.5 (full replacement) and therefore 9.4. + +### B5. Styling/decoration attributes + +- **What it is:** Presentational vocabulary: `before`/`after`/`sep` literal decorations, + `css`/`style`/`beforeStyle`/`parastyle`/`textStyle` named styles, `showLabels`, `flowType`, + numbering (`number`, `numstyle`, `numsingle`, `cssNumber`), and styling elements + (`properties`, `forecolor`, `bold`, `italic`, `fontsize`, `span`, `para`). Dropped with Info + severity (presentational). +- **Measured prevalence:** the single largest census family — `part@after` 1469, `part@before` + 1467, `part@type` 1066, `part@ws` 940 (see B9), `part@css` 729, `part@sep` 618, + `part@showLabels` 286, `part@style` 179, `part@flowType` 62; elements `properties` 160, + `para` 131, `forecolor` 89, `span` 65, `bold` 52. **Caveat:** this family is dominated by + the 594 jtview/publishing layouts that are explicitly out of detail-import scope; the + detail-lane share is far smaller (418 Info-level `unhandled-attribute` diagnostics total). +- **Addressed by:** 6.2 (named-style resolution from the project stylesheet — still open), + 6.7 density/style tokens (done at first-slice scope). Publishing-lane styling retires with + the dictionary/publishing migration, not this change. +- **Retirement risk if unaddressed:** Moderate for detail surfaces (visual fidelity drift, + separator/label-layout differences caught by Path 3 visual lanes); not a data-loss risk. + Should not by itself block 9.4 where parity bundles pass. + +### B6. Table/browse column XML + +- **What it is:** Two lanes: (a) ``/``/`` inside detail layouts; (b) the + entire XMLViews browse/column vocabulary (`` specs, filter/sort metadata) which the + importer does not even see — browse XML is consumed by `XmlBrowseViewBase : RootSite` + (native-views-audit §8.6). +- **Measured prevalence:** detail lane: `table` 4, `table@columns` 4, `cell` 34, `row` 16. + Browse lane: unmeasured by the coverage report (out of importer scope by construction) — + every browse surface in every area is XML-driven today. +- **Addressed by:** 7.1 (virtualized Avalonia table/browse path over typed definitions) and + 7.2 (XMLViews semantics comparison); control choice settled by 7.6 (owned virtualized table). +- **Retirement risk if unaddressed:** High for any claim beyond the detail pane. The lexicon + browse pane stays XML-bound until 7.1/7.2 land, so 9.4 can only ever be scoped to the detail + region until then. Detail-lane `
` usage is tiny (4) and can be explicitly fallback-ed + under 6.12 meanwhile. + +### B7. Chooser metadata (`chooserInfo`/`chooserLink`) + +- **What it is:** Chooser-dialog configuration embedded in layouts: dialog text/title, + `guicontrol` overrides, flid text params, and cross-tool jump links (`chooserLink + label/tool/type/target`). Dropped today (unhandled elements). +- **Measured prevalence:** `chooserLink` 94 (with `@label`/`@tool`/`@type` 94 each, `@target` + 2); `chooserInfo` 93 (`@text` 41, `@guicontrol` 11, `@flidTextParam` 8, `@textparam` 5, + `@title` 4, `@helpBrowser` 2); plus `slice@chooserDlgHelpTopicID` 7. +- **Addressed by:** 6.3 (service-backed chooser model — in progress; options already + LCModel-sourced) plus 3.16 dialog ownership rules and its pending chooser-launch UIA smoke. + IR needs a typed chooser-metadata block before 9.1 Layer-1 freezes (same reservation concern + as B2/B3). +- **Retirement risk if unaddressed:** Medium-high. Choosers open with wrong/missing titles, + guidance text, and jump links; reference-field workflows degrade. Blocks 9.4 for surfaces + whose fields are chooser-driven (most reference fields). + +### B8. TreeView-heavy views + +- **What it is:** Deep tree presentations: nested possibility-list choosers (semantic domains), + multi-translation sense/term trees (6.4), and the record-bar tree. Not an XML-vocabulary gap + per se but a rendering-capability gap: Avalonia `TreeView` does not virtualize, so naive + ports of tree-heavy XML views will not scale. +- **Measured prevalence:** indirect — the chooser metadata above (B7) plus the 7.6 analysis; + `control-selection-matrix.md` bounds stock `TreeView` to ≤~500-node popups. +- **Addressed by:** 7.6 decision matrix (done, recommended-not-decided: flatten in the model, + own the virtualized row); 6.4 rendering spike (analysis done, spike remains). +- **Retirement risk if unaddressed:** Medium. Large semantic-domain/possibility trees freeze + or scroll badly; accessibility tree explodes. Gates choosers (B7) and any tree-shaped + surface's 9.4. + +### B9. Parameter substitution (`$ws`, `$fieldName`, `{0}`, `param`) + +- **What it is:** Runtime substitution in attribute values. The importer consumes handled + attributes literally and flags `$`/`{0}` values with `param-substitution` Info diagnostics + (`XmlLayoutImporter.cs:410-430`); unhandled ws-bearing attributes are dropped. Closely tied + is the slice editor-config content the importer drops: `properties` 160 and `deParams` 126 + elements (the bulk of the 288 `slice-content-dropped` diagnostics). +- **Measured prevalence:** `part@ws` 940, `part@wsType` 730, `deParams@ws` 102, + `layout@tagForWs` 87, `string@ws` 207, plus `param`-carrying caller refs throughout. +- **Addressed by:** 6.2 covers the most important case semantically (per-current-WS row + expansion for "all vernacular"/"all analysis"); full `$ws`/`$fieldName` expansion is Layer-3 + compile-time expansion in the canonical design, implemented under 9.2. `deParams`/ + `properties` need typed editor-parameter representation before 7.4 scales. +- **Retirement risk if unaddressed:** High. Wrong writing-system rows, wrong editor + parameterization (visibility-per-ws, default-ws choices), and broken per-ws labels — subtle + data-display corruption rather than missing fields, which makes it the most dangerous family + to half-fix. Gates 7.4/7.5 and 9.4. + +### B10. Cross-class part resolution + +- **What it is:** Layouts reference parts defined on other classes (base classes, owned-object + classes); legacy `DataTree` walks the class hierarchy and the object graph at realize time. + The importer resolves same-class refs plus an explicit base-class fallback chain added by + 4.10 (`MoStemAllomorph → MoForm`, etc. in `LexicalEditFirstSlice`); everything else surfaces + as `unresolved-part` Errors or `cross-object-deferred` Info. +- **Measured prevalence:** **259 `unresolved-part` (Error)** — the largest Error-class count in + the report — plus `injected-child-dropped`/`cross-object-deferred` occurrences. +- **Addressed by:** generalizing the 4.10 fallback chain from a hand-maintained map to + metadata-driven class-hierarchy resolution; needed by 7.4 scaling and by 9.2's "prove + migration on shipped layouts" claim (9.3). No dedicated task number — should be scheduled + with 7.4. +- **Status (2026-06-11):** resolved down to the legacy floor. `unresolved-part` dropped + **283 → 63** occurrences (the register's original 259 was the 4.9 snapshot; 283 was the + committed pre-fix baseline after the 15.x recovery changes). Two legacy-faithful rules landed + in `DictionaryPartResolver`/`LayoutImportCoverage`: (a) the coverage/compile lanes now walk + the full metadata-driven subclass→base chain (the hierarchy parsed from the LCModel master + model via `LayoutImportCoverage.BuildBaseClassMap`, matching `DataTree.cs:2444-2461`'s + `GetBaseClsId` loop and the MDC-derived map `FullEntryRegionComposer.CompileForClass` already + threads at runtime), and (b) part-id lookup is case-insensitive, matching + `Inventory.GetElementKey`'s lowercasing (`Inventory.cs:1516`). The remaining 63 occurrences + (35 distinct `class-ref` identities, e.g. `LexSense-HeavySummary`, + `RnGenericRec-DateCreated/DateModified` ×12 each) are refs with **no** matching + `{class-chain}-Detail-{ref}` part anywhere in the shipped inventories — legacy `DataTree` + silently omits them too ("Just omit the missing part", `DataTree.cs:2455-2457`; the detail + lane has no PartGenerator). The exact remaining set is pinned by + `LayoutImportCoverageTests.ShippedLayouts_UnresolvedParts_AreExactlyTheLegacyUnresolvableSet` + and enumerated in the regenerated `layout-import-coverage.md` ("Unresolved part refs (B10)"). +- **Retirement risk if unaddressed:** ~~Severe~~ Cleared to legacy parity for the detail lane: + every part legacy resolves, the importer resolves; what remains unresolved is exactly what + legacy also drops. Residual risk moves to B1/B9 (the dropped refs that legacy *renders* + come from ``/custom-field expansion, not part lookup). + +### B11. Dynamic/assembly-loaded custom editors + +- **What it is:** `` loads editor classes at runtime + (`SliceFactory` `custom`/`autocustom` path); 88 slices classify as `dynamic-editor`. Each + needs an explicit Avalonia editor mapping (3.8 inventory done via `EditorKindMap`). +- **Measured prevalence:** `dynamic-editor` 88 (Info); `slice@assemblyPath` 54, + `slice@class` 54; `unknown-editor` reduced to 1 by 4.10's case-insensitivity fix. +- **Addressed by:** per-editor work in 6.x and the 7.4 parity checklist; the Lexicon custom + slices are named Blocker rows in `native-views-audit.md` §8.1.4/8.2. +- **Status (2026-06-11) — hybrid companion-strip lane for WinForms-only custom slices:** the + LexEntry **Messages** slice (`LexEntryParts.xml` `LexEntry-Detail-Messages`, editor="Custom" + class=`SIL.FieldWorks.XWorks.LexEd.MessageSlice`, which hosts the Chorus + `Chorus.UI.Notes.Bar.NotesBarView` — a WinForms control that cannot render inside Avalonia) + now works while the Avalonia surface is active, via a hybrid lane rather than an Avalonia + rewrite. The importer keeps the slice's `class`/`assemblyPath` identity on the typed node + (`ViewNode.CustomEditorClass/CustomEditorAssembly`; the attributes still count as unhandled + in the coverage report since no Avalonia editor consumes them, and the canonical JSON lane + does not yet persist this identity — it must before any 9.4 flip on a surface that relies on + the companion strip), the composer carries it on + the slice's placeholder row keyed by StableId (`ComposedEntryRegion.CustomEditorFields`; + for Messages the placeholder is a read-only `Self` row, not an unsupported row), and + `RecordEditView` promotes designated classes (`AvaloniaCompanionSlices`, today only + MessageSlice): it instantiates the real legacy slice through `DynamicLoader` + the DataTree + install recipe (Cache → Object → FinishInit), docks its NotesBarView in a WinForms + "companion strip" above the Avalonia host (`PocWinFormsHostControl.SetCompanionControls`), + and removes the grey unsupported row from the model the region shows. If Chorus/S-R is + unavailable the lane degrades to nothing (logged), never an error row. Known trade-off: the + strip is pinned above the region, not at the slice's legacy mid-tree position between Import + Residue and Senses. Covered by `FullEntryRegionMessagesCompanionTests`; the Chorus half is + manual-verification (notes bar + add-note icon against an S/R project). +- **Retirement risk if unaddressed:** High for affected fields (reversal entries, MSA + launchers, phonological features render as the resource-backed unsupported state at best). + Per-surface 9.4 must enumerate which dynamic editors that surface's layouts reach; the + companion strip is the documented lane for the ones that must stay WinForms (Chorus UI). + +### B12. Native viewing/render coupling + +- **What it is:** Even with XML fully imported, a surface cannot retire runtime XML while its + rendering still goes through legacy `DataTree`/RootSite, because that stack *is* the XML + consumer. ~21 slice/view classes are RootSite/Views-backed (multistring/string/ghost/StText/ + summary/reference/type-ahead/media + custom lexicon slices), with `MultiStringSlice` (the + most common interaction) replaced only when gate 6.13 (TsString read/write, per-WS + fonts/keyboards, IME composition, RTL/bidi) passes. +- **Measured prevalence:** see `native-views-audit.md` §8.1.3/8.1.4 (full table with + file:line); the multi-WS plain-text lane exists, the TsString rich lane does not. +- **Addressed by:** 6.13 (GATE), 6.x editors, 8.3 (managed replacement), 8.4 (done — + enforcement via `EngineIsolationAuditTests`), 8.5 (adapter removal after replacement tests). +- **Retirement risk if unaddressed:** Absolute. While any field on a surface falls back to the + legacy slice stack, that surface still loads layout XML at runtime; 9.4 for that surface is + definitionally impossible. This family sequences *after* all others: B1–B11 make the typed + definitions complete; B12 makes them the only renderer. + +--- + +## Cross-cutting schema deadline + +B2 (ghost), B3 (conditionals), and B7 (chooser metadata) share one deadline: the canonical +JSON schema must **reserve their representation before Layer-1 shipped-definition generation +freezes** (`canonical-view-definition-design.md` §5 open question 4), or the generated files +take an early breaking `formatVersion` bump. This is a design-ordering blocker for 9.2, ahead +of any runtime work. **Status (2026-06-11): B2 and B3 representations are now reserved and +shipping** — ghost metadata (`ghost`/`ghostWs`/`ghostClass`/`ghostLabel`/`ghostInitMethod`) +and the `condition` object both serialize/deserialize in `ViewDefinitionJsonSerializer` with +round-trip coverage; only B7's chooser-metadata block remains to reserve. + +## The gate, restated + +Runtime XML retires **per-surface only** (canonical design §4 step 5; tasks 9.4/9.5): + +1. A migrated surface may disable runtime XML resolution (9.4) only after the blocker families + above are clear **for the layouts that surface actually loads** — custom fields expand, + ghosts realize, conditionals evaluate, menus/hotlinks and choosers function, ws/param + substitution is correct, every part resolves, every reachable editor has an Avalonia + mapping, and no field on the surface falls back to the native Views stack. +2. While disabled, the XML import/audit lane remains available as the documented rollback + (9.4), and the dual-run gate (XML-import vs JSON-load snapshot equality) must hold. +3. Non-migrated legacy surfaces keep reading XML untouched until they are themselves migrated; + the canonical format never needs to serve legacy `DataTree`. +4. Repo-wide XML deletion additionally waits on the consumers outside this change + (`native-views-audit.md` §8.6: browse views in all areas, Interlinear, Notebook/Grammar/ + Lists detail, publishing layouts) — none of which this register authorizes. diff --git a/openspec/changes/lexical-edit-avalonia-migration/xmlviews-table-semantics.md b/openspec/changes/lexical-edit-avalonia-migration/xmlviews-table-semantics.md new file mode 100644 index 0000000000..a963753d9c --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/xmlviews-table-semantics.md @@ -0,0 +1,351 @@ +# XMLViews Table Semantics vs Typed IR vs Avalonia Table Path (Task 7.2) + +> Comparison of the legacy XMLViews browse/table stack against (a) the typed view-definition IR +> (`Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs`) and (b) the planned Avalonia +> table path per `control-selection-matrix.md` (FieldWorks-owned virtualized table over +> `ListBox`/`VirtualizingStackPanel` primitives). Every legacy claim carries a verified +> file:line citation against the live branch. Feeds the 7.1 build iterations. +> Date: 2026-06-09. + +All legacy paths are relative to `Src/Common/Controls/XMLViews/` unless otherwise noted. +Abbreviations: **BV** = `BrowseViewer.cs`, **XBVB** = `XmlBrowseViewBase.cs`, +**VC** = `XmlBrowseViewBaseVc.cs`, **FB** = `FilterBar.cs`, **BEB** = `BulkEditBar.cs`, +**DDC** = `XMLViewsDataCache.cs`, **LF** = `LayoutFinder.cs`. + +## Legacy XMLViews table semantics (inventory) + +The legacy browse surface is a composite: `BrowseViewer` (the `XCoreUserControl` shell, +BV:170) owns a `DhListView` header (BV:196, BV:914), an optional `FilterBar` (BV:205, +BV:995–1005), an optional `BulkEditBar` (BV:209, BV:1012–1015), and the actual rows in +`XmlBrowseView : XmlBrowseViewBase : RootSite` (XBVB:28) rendered by the native Views engine +via `XmlBrowseViewBaseVc : XmlVc` (VC:36). + +### 1. Column definition source + +- Columns are authored as `` children of a `` element in the tool/area + configuration XML, e.g. `DistFiles/Language Explorer/Configuration/Lexicon/areaConfiguration.xml:84–91`: + ``. +- The VC computes the **possible** column pool from the spec via + `PartGenerator.GetGeneratedChildren(m_xnSpec.SelectSingleNode("columns"), ...)` (VC:596–602), + which also **generates columns for custom fields** from the metadata cache, keyed by the + list-items class (`ListItemsClass`, VC:608–637; from the sort-item provider or the mandatory + `listItemsClass` attribute, VC:619–626). +- **Default active set** = possible columns with `visibility="always"` (VC:212–216); + `visibility="menu"` columns stay in the pool, available from the header menu/config dialog. +- **Per-user column set persistence**: the active column list is saved as an XML string in the + `PropertyTable` under a per-tool key (`ColListId`, VC:103–111; saved in + `BrowseViewer.UpdateColumnList`, BV:3022–3074) with a schema version + (`kBrowseViewVersion = 20`, BV:2972) and an explicit **version-migration ladder** on load + (`GetSavedColumns`, VC:270–309 and onward). Hidden-column tracking distinguishes + "removed by user" from "new column added by an upgrade" via `` sentinels + (save BV:3046–3072; load VC:233–264). Columns marked `doNotPersist="true"` suppress saving + (BV:3030–3031); `normalLayout` is persisted in place of a special edit layout (BV:3032–3040). +- **Column-spec attribute vocabulary** (the table-semantics surface area): + - `label` / `originalLabel` (identity + custom-field relabel, VC:653–658, VC:642–668) + - `width` in millipoints (default-width selection comment VC:211; example fixtures + VC:287–301; live spec areaConfiguration.xml:84) + - `ws` with `$ws=` magic values (`vernacular`, `analysis`, `best vernoranal`, `reversal`, …) + resolved per row via `WritingSystemServices.GetWritingSystem`/`FindWsParam` + (FB:983–989, VC:1045–1053) + - `layout` (cell content = named part/layout, LF:84) or inline view content under the column + node (VC:1146–1151) + - `sortmethod` (C# sort-key method on the model object → `SortMethodFinder`, LF:85–91) and + `sortType` (`integer`, `date`, `genDate`, `YesNo`, `stringList`, `occurrenceInContext` → + specialized finders, LF:92–115) + - `cansortbylength` (BV:3230–3234) + - `editable`, `transduce`, `commitChanges`, `editif` (in-cell editability, VC:1471–1517) + - `blankPossible`, `multipara` (filter combo seeding, FB:964–977; multipara also switches + cell flow, VC:1132–1140) + - `bulkEdit` / `chooserFilter` (list-choice filter + bulk-edit target wiring, FB:991–993, + BEB:672) + - `common` (auto-add of new shipped columns to upgraded saved sets, VC:256–264) + - view-level (not per-column) attributes: `selectColumn` (check-box column, VC:562–576) and + `convertDummiesInView` (XBVB:481–484), `disableConfigButton` (BV:1053). + +### 2. Cell rendering + +- Each row is rendered as a **one-row Views table per object**: `AddTableRow` opens + `vwenv.OpenTable(colCount, …)` with per-column `VwLength` widths taken from the live header + (VC:901–1012; widths VC:938, sourced from `BrowseViewer.GetColWidthInfo` converting header + pixel widths to point-1000 `VwLength`s at current DPI, BV:2207–2228). +- `AddTableCell` (VC:1038–1181) per cell: + - resolves the column's **writing system per row** (`GetBestWsForNode`, VC:1045) and derives + **RTL alignment** (VC:1055–1072) and paragraph direction (VC:1105–1110); audio (voice) + writing systems force the cell non-editable (VC:1074–1076); + - wraps content in a paragraph unless `multipara="true"` (VC:1132–1140); + - when a sort-item provider exists, the cell content is produced through the row's + `IManyOnePathSortItem` **path** (`DisplayCell`, VC:1159–1168 and VC:1410–1439) so a "row" + can represent a child object (e.g. a sense) with cell content pulled from anywhere on the + path; otherwise the column node's children are processed directly (VC:1142–1158); + - a column-level forced WS (`m_wsForce`, VC:78–85, set in VC:1148/1421) overrides multistring + WS resolution inside the cell. +- **Whole-column RTL order**: `m_fShowColumnsRTL` reverses column iteration (VC:92, VC:999–1008). +- **Row chrome**: border color, selected-row background/border highlight per + `SelectionHighlighting` mode (VC:903–935), tentative-color constant for RDE (VC:51). +- Cell hotlinks launch FW links (`DoHotLinkAction`, VC:1758–1781); embedded ORC objects are + suppressed (VC:1750–1752). +- **In-cell editing** is real Views editing on the *current row only* (`SetCellProperties` + gates editability to `rowIndex == SelectedIndex`, VC:1243–1248): editable when + `editable="true"`, or `transduce` present and not `editable="false"` (optionally guarded by + an `editif` method probe via reflection), and never when `commitChanges` is set + (`AllowEdit`, VC:1471–1517). Click-copy mode forces exactly one editable column + (`OverrideAllowEditColumn`, VC:737, VC:908–912, VC:1481–1482). A separate rapid-data-entry + subclass (`XmlBrowseRDEView.cs`, VC fragment `kfragEditRow` VC:46) supports row-entry editing. + +### 3. Sorting + +- Sorting is **not done by the view**: the browse surface only *builds and publishes* a + `RecordSorter`; the record clerk/list applies it (`RecordSorter.Sort/MergeInto` contract, + `Src/Common/Filters/RecordSorter.cs:276–284`). +- Header **left click** picks the column's `FilterSortItem.Sorter` and raises `SorterChanged` + (BV:2277–2328 via `SetAndRaiseSorter`, BV:716–722); clicking the active column **reverses** + it (BV:2293–2298, BV:2330–2349); **Shift+click builds a multi-column `AndSorter`** + (BV:2299–2325). +- Column sorters are `GenRecordSorter(new StringFinderCompare(finder, new WritingSystemComparer(ws)))` + (FB:796–807); the finder comes from `LayoutFinder.CreateFinder`, which dispatches on + `sortmethod` → `SortMethodFinder`, `sortType` → `IntCompareFinder`/`OccurrenceInContextFinder`/ + plain `LayoutFinder`, else plain `LayoutFinder` over the cell layout (LF:81–122). Sort keys + are strings produced per `IManyOnePathSortItem` (`SortStrings`, LF:283; SortMethodFinder + key walk LF:514–656), compared with ICU writing-system collation. +- **Sort arrows** in the header reflect the active (possibly compound) sorter with large/medium/ + small arrows for precedence (`SyncSortArrows`, BV:737–788; `DhListView.ShowHeaderIcon`, + `DhListView.cs:696`). +- Extra sort modes per column: **sort from end** (BV:2358–2456) and **sort by length** + (`cansortbylength`, BV:3215–3236), surfaced as xCore commands. +- Sorting requires the filter bar to exist (BV:2279–2280); `InitSorter` reconciles a persisted + clerk sorter with current columns on load/column change (BV:1188–1264). + +### 4. Filtering + +- The `FilterBar` renders **one `FwComboBox` per active column**, aligned under the header + columns (`MakeOrReuseItems`, FB:560–585; `SetColWidths`, FB:703–731). +- Combo contents are seeded from column attributes (`MakeCombo`, FB:954–1077): Show All; + Blanks/Non-blanks (`blankPossible`, FB:964–971); line-count matchers (`multipara`, + FB:972–977); `sortType`-specific presets — integer zero/ranges + Restrict dialog + (FB:996–1012), date Restrict (FB:1013–1021), Yes/No (FB:1022–1029), `stringList` + exact/exclude matchers (FB:1030–1047); spelling-errors matcher when a dictionary exists + (FB:1048–1054); **"Filter for…"** free-text `FindComboItem` (FB:1056) supporting match + patterns; and a **list-choice chooser filter** when `bulkEdit`/`chooserFilter` is set + (FB:991–993, FB:1058–1061). +- A cell filter is `FilterBarCellFilter(finder, matcher)` (FB:1567, + `Src/Common/Filters/RecordFilter.cs:2396`); multiple active cells combine into an + `AndFilter` (`RecordFilter.cs:2559`). Filters accept/reject rows by `IManyOnePathSortItem` + (`RecordFilter.Accept`, `RecordFilter.cs:211`) and are applied by the clerk, not the view: + the bar raises `FilterChanged` which `BrowseViewer` forwards (BV:244, FB:944–948) and the + clerk persists. +- On reload/column change the bar **re-binds persisted clerk filters to combos** + (`UpdateActiveItems`/`ActivateCompatibleFilter`, FB:593–658; finder identity via + `SameFinder`, FB:685–690), and removes user-visible filters that no longer match a column + (FB:653–657). +- Filter combos carry stable nonlocalized automation IDs (`FilterCombo.*`, FB:1075–1098) — + already baselined by `WinFormsUiaSmokeTests` (task 2.4). +- Bar height adapts to writing-system font sizes (FB:629–635). + +### 5. Selection model + +- **Single current row** (`SelectedIndex`, XBVB:263 ff.) with `SelectedIndexChanged` events + (XBVB:45, XBVB:354–355); the selected object is `m_sda.get_VecItem(m_hvoRoot, m_fakeFlid, + SelectedIndex)` (XBVB:230–236). There is **no multi-row selection**; multiplicity comes from + the check-box column (below). +- Row highlight modes `all`/`border`/`none` (`SelectionHighlighting`, XBVB:894–902; chosen from + editability/ReadOnlySelect, XBVB:1582–1591), repainted by faking a `PropChanged` on a + special per-row tag (`TagMe`, VC:581–587, XBVB:928–933) so only the affected rows re-lay out. +- Views text selections map back to a row index (`HandleSelectionChange`, XBVB:1966–1987; + `GetRowIndexFromSelection`, XBVB:663); clicks select a row, and in `ReadOnlySelect` mode the + click is intercepted without installing an editing selection (`XmlBrowseView.OnMouseUp`, + `XmlBrowseView.cs:146–219`); Up/Down arrows move the selected row in read-only mode + (`XmlBrowseView.cs:116–139`); selection is scrolled into view (`DoSelectAndScroll`, + XBVB:1684–1697; `MakeSelectionVisible`, XBVB:2208–2210). +- Selection survives reconstruct/scroll via saved scroll/selection state + (XBVB:487–493, `XmlBrowseViewSelectionRestorer.cs`). + +### 6. Check-box bulk-edit columns + +- The check column is enabled by `selectColumn="true"` on the view spec (`SetupSelectColumn`, + VC:562–576) and rendered as a leading cell with an **integer-property picture** bound to the + decorator tag `ktagItemSelected` (`AddSelectionCell`, VC:1314–1362; checked/unchecked picture + selection in `DisplayPicture`, VC:1676–1688); a disabled picture is shown when + `ktagItemEnabled` is off (VC:1334–1351). +- Check state, enabled state, preview values, and the active preview column live in a + **decorator SDA**, not the model: `XMLViewsDataCache` defines `ktagItemSelected` (90000000), + `ktagItemEnabled`, `ktagActiveColumn`, `ktagAlternateValue`, `ktagAlternateValueMultiBase` + (DDC:39–71) with default-checked semantics (DDC:34–39). +- Clicking the check cell toggles it through normal Views editing of the int property + (`XmlBrowseView.cs:204–210` routes the click; editability forced on in VC:1340–1358); + changes raise `CheckBoxChanged(hvosChanged)` (BV:253, BV:421–437). +- **Check-all header button** with CheckAll/UncheckAll/Toggle menu (BV:1032–1045, + BV:3356–3367; `ResetAll`, BV:3548–3569), `AllItems` enumeration (BV:3377) and programmatic + `SetCheckedItems` (BV:3424). +- The **BulkEditBar** consumes the checked set: operation tabs List Choice, Bulk Copy, Click + Copy, Process (transduce), Find/Replace, Delete (BEB:52–56, BEB:55, BEB:120), with per-tab + enable flags from XML (BEB:260–270); targets are declared by `bulkEditListItemsClasses` + (BEB:206–211) and **ghost fields** via `GhostParentHelper` (BEB:214–219; `GhostParentHelper.cs`). +- **Preview semantics**: Preview/Apply buttons (BEB:106–108, BEB:282–283); the preview writes + alternate values into the decorator and marks the active column; the VC then renders + original + arrow + alternate as three inner piles in the cell (VC:1112–1128 layout comment, + `AddPreviewPiles`/`AddAlternateCellContents`, VC:1270–1312; per-cell active test + VC:1078–1094; RTL arrow VC:1289–1297; multi-column preview via + `ktagAlternateValueMultiBase + icol`, VC:1304–1311). + +### 7. Header behavior + +- The header is a real WinForms `ListView` in details mode used **only as a header** + (`DhListView`, `DhListView.cs:21`; BV:914–958; `Scrollable = false`, BV:957; not a tab stop, + BV:958). +- **Resize**: dragging persists per-column pixel widths in the `PropertyTable` under + `{tool}_{view}Column_{i}_Width` keys (`SaveColumnWidths`/`FormatColumnWidthPropertyName`, + BV:2183–2199); `AdjustColumnWidths` pushes new `VwLength`s into the root box and the filter + bar (BV:2131–2156); minimum column width 25 px (`kMinColWidth`, `DhListView.cs:607`); + proportional fill on first layout (`MaximizeColumnWidths`, BV:2233–2251). +- **Reorder**: drag-and-drop column reordering (`AllowColumnReorder`, BV:955; + `ColumnDragDropReordered` event, BV:954, `DhListView.cs:46,174`), with a display-order + mapping (`OrderForColumnsDisplay`, BV:2282). +- **Column choosing**: header right-click menu (BV:953) and the blue-arrow configure button + (BV:1052–1076) open the column menu / `ColumnConfigureDialog.cs`; changes flow through + `UpdateColumnList` (BV:2977–3075) which rebuilds filter bar, sorter, bulk-edit bar, and + persists the set. +- **Sort arrows** drawn into header icons (BV:776–788, `DhListView.cs:696`). + +### 8. Virtualization / lazy behavior + +- Rows are **lazy by construction**: the root display adds the whole list as a lazy vector + (`vwenv.AddLazyVecItems(m_fakeFlid, this, kfragListItem)`, VC:1643–1651); the native Views + engine materializes rows on scroll using the VC's `EstimateHeight` (a flat 17-point guess, + VC:1740–1743). `LoadDataFor` is a no-op because all data is already in the SDA decorator + (VC:1699–1703). +- Row identity is a **fake flid** (`m_fakeFlid`, VC:57) published by the record list through + the `XMLViewsDataCache` decorator over `DomainDataByFlid` (DDC:24); each lazy item indirects + through `TagMe` (`kfragListItem` → `AddObjProp(m_tagMe, …, kfragListItemInner)`, + VC:1653–1658) so single rows can be invalidated cheaply. +- Dummy-to-real object conversion can be deferred to paint time + (`ShouldConvertDummiesInView`, XBVB:481–484; `InOnPaint` handshake, VC:1705–1719, + XBVB:1952–1957). +- The view participates in `IVwNotifyChange` so model edits repaint affected rows (XBVB:28); + scroll-range adjustments are specially handled for the table layout (XBVB:504 ff.). + +## What the typed IR expresses today vs gaps + +Reference: `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs`. + +### Expressed today + +- `ViewNodeKind` covers exactly the **detail-view** vocabulary: `Field`, `Group`, + `ObjectAtom`, `Sequence`, `CustomFieldPlaceholder` (ViewDefinitionModel.cs:19–35). There is + **no table/column/row kind**. +- `ViewNode` carries: `StableId`, `Label`, `Abbreviation`, `Field`, `RawEditor` + + `EditorClassification`, `WritingSystem` (string, `$ws`-style), `Visibility` + (`Always`/`IfData`/`Never`, :37–48), `Expansion`, `Indented`, `TargetLayout`, `Children`, + plus task-4.7 metadata `LocalizationKey`, `AutomationId`, `SurfaceRouting` + (ViewDefinitionModel.cs:150–232). +- `ViewDefinitionModel` carries `ClassName`/`LayoutName`/`LayoutType`, roots, and a + **diagnostics channel** with stable codes and node paths (:121–143, :238–262), plus the + deterministic `ToSnapshot` baseline format (:269–313). +- The import pipeline (`XmlLayoutImporter`, `ViewDefinitionCompiler`, `ViewDefinitionCache`, + `LayoutImportCoverage`) handles **detail layouts only**; nothing in + `Src/Common/FwAvalonia/ViewDefinition/` parses a ``/`` browse spec (verified + by search across the folder — the words column/table/sort/filter occur only in coverage-report + bookkeeping and importer comments). + +Several legacy table concepts have *near* analogues worth reusing rather than duplicating: +a `` is shaped like a `Field`/`ObjectAtom` with `TargetLayout`; generated +custom-field columns parallel `CustomFieldPlaceholder` (both ride `PartGenerator`-style +expansion); `multipara` cells are `Sequence`-shaped; the diagnostics/`StableId`/snapshot +infrastructure applies unchanged to a table definition. + +### Gaps (precise) + +1. **No table container kinds.** Nothing represents "a browse view over listItemsClass C with + columns [...]": no `Table`/`Column` `ViewNodeKind`, no `listItemsClass` slot on + `ViewDefinitionModel`. +2. **Visibility vocabulary mismatch.** Browse columns use `visibility="always"|"menu"` + (default-active vs available-in-pool, VC:212–216); IR `ViewVisibility` is + `Always`/`IfData`/`Never` — `IfData` has no browse meaning and "menu" (pool membership) is + inexpressible. +3. **No column presentation metadata**: `width` (millipoints + per-user pixel persistence), + `common`, `originalLabel`, `doNotPersist`, `normalLayout`, column order / RTL column order. +4. **No sort metadata**: `sortmethod`, `sortType`, `cansortbylength`, sort-from-end, default + sorter, multi-sort precedence. The IR has no concept that a node yields a *sort key*. +5. **No filter metadata**: `blankPossible`, `multipara` (as filter affordance), + `sortType`-driven preset matcher sets, `bulkEdit`/`chooserFilter` list-choice filters — + i.e., no way to declare what filter UI a column offers. +6. **No editability/bulk-edit metadata**: `editable`, `transduce`, `commitChanges`, `editif`, + `selectColumn`, `bulkEditListItemsClasses`, ghost-field declarations (ghost state was + explicitly deferred in 4.1; the browse path makes it load-bearing via `GhostParentHelper`). +7. **No row-identity/path concept.** Legacy rows are `IManyOnePathSortItem`s (an object plus + the ownership path it was reached by), which is what makes senses-as-rows, path-aware cell + display (VC:1410–1439), sorting (LF:283), and filtering (`RecordFilter.cs:211`) coherent. + The IR has no row/path abstraction at all (it describes one object's detail view). +8. **No persistence contract** for the user-modified column set / widths / version migration + (`kBrowseViewVersion` ladder). The 9.1 canonical-format design covers detail overrides keyed + by `StableId`; browse column sets are a second, currently unmodeled override family. +9. **`WritingSystem` is carried but unresolved** — fine for detail slices, but table semantics + need the resolved-per-row "best" behavior (VC:1045) and the audio/RTL consequences to be a + *contract* (per-cell WS resolution rule), not an importer string. + +## Avalonia mapping (per `control-selection-matrix.md`) + +Target per the matrix §"Browse/table view": **FieldWorks-owned virtualized table — flattened +row list over `VirtualizingStackPanel`, owned shared-scope column header bar, owned cell +layout (uniform column grid via lightweight panel), stock `ListBox` selection** — with +`TreeDataGrid` rejected on licensing/editing/automation and named as the pivot option. + +Disposition legend: **maps cleanly** (stock primitive or existing seam covers it) / +**needs IR extension** (new typed metadata per gaps above) / **needs owned-control feature** +(code we write in the owned table) / **stays legacy-side during coexistence**. + +| Legacy semantic | Avalonia disposition | +|---|---| +| Column definition from XML + custom-field generation (VC:596–602) | **Needs IR extension** (table/column kinds, gap 1–3) + importer lane for ``; custom-field columns reuse the `CustomFieldPlaceholder` expansion pattern from 4.10. | +| Default-vs-menu column pool, user column set, version migration (VC:212–265, BV:3022–3074) | **Needs IR extension** (visibility vocabulary, gap 2) + a persistence decision shared with 9.1 (gap 8). Reading the legacy `ColListId` saved XML at import keeps user sets during coexistence. | +| Row = `IManyOnePathSortItem` path (VC:1410, LF:283) | **Needs IR extension** (row/path model, gap 7) — the owned table's items source should be path-aware row models, mirroring "flatten in the model". | +| One-row-table cell rendering, per-cell WS/RTL/font (VC:1038–1181) | **Maps cleanly** to owned row templates (matrix: full `DataTemplate` control is the design center); per-cell grid is the owned lightweight panel. Rich TsString cells are **gated by 6.13**; plain-text multi-WS cells work today. | +| Mixed row heights (`multipara` cells) under virtualization | **Maps with risk**: `VirtualizingStackPanel` estimated-size behavior is pivot trigger 2 in the matrix; validate against 10k-row fixtures (7.7). Legacy's own estimate is a flat 17 pt (VC:1740–1743), so parity does not require per-row estimation accuracy. | +| Lazy row realization (VC:1650) | **Maps cleanly** — `VirtualizingStackPanel` realization replaces `AddLazyVecItems`; data is already fully in memory in legacy too (VC:1699–1703), so no async-data story is required for parity. | +| Single current row + highlight + scroll-into-view (XBVB:263, 894–902, 1684) | **Maps cleanly** — stock `ListBox` selection + `BringIntoView`; current-record sync rides the existing 3.12 `IRecordNavigationContext` bridge. | +| Header: resize/reorder/sort arrows/column menu (BV:914–958, 2131–2199, 2277) | **Needs owned-control feature** — the owned header bar (matrix decision). Persist widths via the same property keys during coexistence (BV:2193–2199) or the new format per gap 8. | +| Sort: header click → `RecordSorter` to clerk (BV:2277–2328) | Split: sort-affordance metadata **needs IR extension** (gap 4); arrow UI **needs owned-control feature**; the *sorting itself* **stays legacy-side during coexistence** — the owned table should publish a `RecordSorter` through the clerk exactly as `BrowseViewer` does, since sorted order is clerk state shared with other surfaces. | +| Filter bar: per-column matcher combos (FB:954–1077) | Split: filter-affordance metadata **needs IR extension** (gap 5); the filter row UI **needs owned-control feature** (combos/flyouts per 6.3); matchers/`FilterBarCellFilter`/`AndFilter` **stay legacy-side** (LCModel-free `Filters` assembly consumed through a seam) so clerk filter state remains shared. Keep the `FilterCombo.*` automation-ID contract (FB:1080–1098) for the 2.4 baselines. | +| Check-box column + decorator tags (VC:562–576, DDC:39–73) | **Needs owned-control feature** (a real checkbox cell — simpler than int-prop pictures) + **IR extension** for `selectColumn` (gap 6). Check-state storage should move from decorator-SDA tags to a managed selection-set service (matrix "Managed selection"), bridged to `XMLViewsDataCache` only if a legacy `BulkEditBar` must drive an Avalonia table during coexistence. | +| Bulk edit bar, preview arrows, apply (BEB, VC:1270–1312) | **Stays legacy / out of first scope.** Bulk edit is a workflow on top of the table (own tabs, ghost handling, undoable apply). First Avalonia table iterations should target read/select/sort/filter browse parity; bulk-edit surfaces remain on the legacy `BrowseViewer` via explicit fallback (6.12 pattern) until a dedicated change. | +| In-cell editing (`editable`/`transduce`, VC:1471–1517) | **Gated by 6.13** (TsString editor foundation) + **IR extension** (gap 6). Not needed for first browse parity: most shipped columns are `editable="false"` (areaConfiguration.xml:84–91). | +| Click copy (XmlBrowseView.cs:177–199) | **Stays legacy / out of first scope** — bulk-edit-tab workflow. | +| Row repaint on model change (XBVB:28 `IVwNotifyChange`) | **Maps cleanly** — the 3.15 `AvaloniaRegionRefreshController` pattern generalizes: subscribe the notify bus, re-resolve affected row models. | + +## Gaps list feeding 7.1 iterations + +Ordered so each iteration of 7.1 (virtualized table path) has a concrete contract to build against: + +1. **IR: table vocabulary.** Add `Table` (or a `TableDefinitionModel` peer) + `Column` node + kinds carrying: label/originalLabel, width hint, ws spec, target layout, visibility + (`Active`/`Pool` — fix the gap-2 vocabulary), sort metadata (`sortmethod`, `sortType`, + `cansortbylength`), filter affordances (`blankPossible`, `multipara`, preset family, + list-choice ref), editability flags, `selectColumn`, `listItemsClass`, and stable IDs. + Reuse `ViewDiagnostic`/`ToSnapshot`. +2. **Importer lane for `` specs**, including `PartGenerator`-equivalent custom-field + column generation and reading the legacy `ColListId` saved-column XML (with the + version-20 ladder) so user column sets survive; extend `LayoutImportCoverage` to census the + browse vocabulary the same way 4.9 did for detail layouts. +3. **Row model**: a path-aware row abstraction equivalent to `IManyOnePathSortItem` + (object + ownership path), produced by the clerk-side provider, consumed by the owned table + as its flattened virtualized items source. +4. **Owned header bar**: resize (persisting widths), drag reorder, sort-arrow display with + multi-sort precedence, right-click column menu / config entry point; keep header reachability + automation IDs compatible with the 2.4 UIA baselines. +5. **Sort/filter bridge seam**: owned table publishes `RecordSorter`/`RecordFilter` changes to + the real clerk (reusing `Filters` types through a narrow interface) and re-binds persisted + clerk sort/filter state to columns on load — the Avalonia analogue of `InitSorter` + (BV:1188) + `UpdateActiveItems` (FB:593). +6. **Filter row control**: per-column combo/flyout with the matcher families seeded from the + column's filter metadata, including the free-text "Filter for…" path and stable + `FilterCombo.*` automation IDs. +7. **Selection service**: single current row bridged via `IRecordNavigationContext` (3.12), + plus a managed checked-set service for the future check-box column (decoupled from + `XMLViewsDataCache` tags). +8. **Per-cell WS resolution contract**: codify the `GetBestWsForNode` behavior (best/reversal/ + audio/RTL consequences) as a service the row templates consume, shared with 6.2. +9. **Performance fixtures**: 10k+-row virtualization benchmarks (open, scroll, sort-apply + re-render) wired into 7.7 budgets; mixed-height fixture to exercise pivot trigger 2. +10. **Explicitly deferred, recorded for 9.5**: bulk-edit bar (tabs, preview/apply, ghost + handling), click copy, in-cell editing (gated on 6.13), RDE row entry + (`XmlBrowseRDEView`) — these keep the legacy `BrowseViewer` as the supported fallback + surface for their tools until separately scheduled. From d88d079cfc202ae25463ad032e77d1aefc3d3016 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 12:11:00 -0400 Subject: [PATCH 04/14] Next round of xml retirement --- Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 6 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 6 + .../FwAvaloniaTests/RegionEditingTests.cs | 14 ++ .../FwAvalonia/Region/FwFieldControls.cs | 101 +++++++++ .../FwAvalonia/Region/IRegionEditContext.cs | 13 ++ .../Region/LexicalEditRegionModel.cs | 30 ++- .../Region/LexicalEditRegionView.cs | 2 + Src/xWorks/FullEntryRegionComposer.cs | 191 +++++++++++++++++- Src/xWorks/RegionEditContextBase.cs | 8 + .../LexicalEditRegionEditingTests.cs | 9 +- .../xml-retirement-blockers.md | 13 ++ 11 files changed, 382 insertions(+), 11 deletions(-) diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index deee83c4be..25ef417d4c 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -39,5 +39,11 @@ public static class FwAvaloniaStrings public static string GhostAddPromptFormat => Resources.GetString("ksGhostAddPrompt"); public static string Copy => Resources.GetString("ksCopy"); + + /// "Remove" — reference-vector item context command (6.3). + public static string Remove => Resources.GetString("ksRemove"); + + /// "Add item" — reference-vector add-slot launcher name (6.3). + public static string AddItem => Resources.GetString("ksAddItem"); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index ffd114a887..40bc34771e 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -60,4 +60,10 @@ Copy Context-menu command copying a field's text. + + Remove + + + Add item + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs index ace7fecec5..9f8c5c9892 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs @@ -25,6 +25,20 @@ internal sealed class FakeRegionEditContext : IRegionEditContext { public readonly List<(string Field, string Ws, string Value)> TextEdits = new List<(string, string, string)>(); public readonly List<(string Field, string Key)> OptionEdits = new List<(string, string)>(); + public readonly List<(string Field, string Key)> ReferenceAdds = new List<(string, string)>(); + public readonly List<(string Field, string Key)> ReferenceRemoves = new List<(string, string)>(); + + public bool TryAddReferenceItem(LexicalEditRegionField field, string optionKey) + { + ReferenceAdds.Add((field.Field, optionKey)); + return true; + } + + public bool TryRemoveReferenceItem(LexicalEditRegionField field, string optionKey) + { + ReferenceRemoves.Add((field.Field, optionKey)); + return true; + } public IReadOnlyList ValidateResult = new List(); public int CommitCount; public int CancelCount; diff --git a/Src/Common/FwAvalonia/Region/FwFieldControls.cs b/Src/Common/FwAvalonia/Region/FwFieldControls.cs index f9ff26081d..922e0463ac 100644 --- a/Src/Common/FwAvalonia/Region/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Region/FwFieldControls.cs @@ -225,4 +225,105 @@ private static string CurrentName(LexicalEditRegionField field) return selected?.Name ?? string.Empty; } } + + /// + /// FieldWorks-owned editable reference-vector field (6.3/B8): the current items rendered + /// inline, each followed by the thin grey separator bar legacy reference slices draw + /// (VwSeparatorBox), with the TRAILING bar fronting the add slot — a "+" launcher whose flyout + /// lists the possibility tree indented by (the legacy + /// chooser tree; virtualized ListBox so the ~1800-node semantic-domain list stays usable). + /// Right-clicking an item offers Remove. Without an edit context the row is read-only display. + /// + public sealed class FwReferenceVectorField : StackPanel + { + public FwReferenceVectorField( + LexicalEditRegionField field, + string automationId, + IRegionEditContext editContext) + { + Orientation = Orientation.Horizontal; + AutomationProperties.SetAutomationId(this, automationId); + AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); + + var editable = editContext != null && field.IsEditable; + foreach (var item in field.Items) + { + var text = new TextBlock + { + Text = item.Name, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 4, 0) + }; + AutomationProperties.SetAutomationId(text, automationId + ".Item." + item.Key); + if (editable) + { + var removeItem = new MenuItem { Header = FwAvaloniaStrings.Remove }; + var key = item.Key; + removeItem.Click += (s, e) => editContext.TryRemoveReferenceItem(field, key); + text.ContextFlyout = new MenuFlyout { Items = { removeItem } }; + } + Children.Add(text); + Children.Add(SeparatorBar()); + } + + if (!editable) + return; + + // The legacy empty add slot: a trailing bar (added above for the last item; one leads + // the launcher when the vector is empty) plus the chooser launcher. + if (field.Items.Count == 0) + Children.Add(SeparatorBar()); + + var addButton = new Button + { + Content = "+", + Padding = new Thickness(4, 0, 4, 0), + MinHeight = 0, + MinWidth = 0, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Foreground = PocDensity.WsAbbrevBrush + }; + AutomationProperties.SetAutomationId(addButton, automationId + ".Add"); + AutomationProperties.SetName(addButton, FwAvaloniaStrings.AddItem); + + var list = new ListBox + { + ItemsSource = field.Options, + MaxHeight = 320, + MinWidth = 180, + ItemTemplate = new Avalonia.Controls.Templates.FuncDataTemplate( + (option, _) => option == null + ? null + : new TextBlock + { + Text = option.Name, + Margin = new Thickness(option.Depth * 14, 0, 0, 0) + }) + }; + AutomationProperties.SetAutomationId(list, automationId + ".Options"); + var flyout = new Flyout { Content = list, Placement = PlacementMode.BottomEdgeAlignedLeft }; + addButton.Flyout = flyout; + list.SelectionChanged += (s, e) => + { + if (list.SelectedItem is RegionChoiceOption option) + editContext.TryAddReferenceItem(field, option.Key); + flyout.Hide(); + list.SelectedItem = null; + addButton.Focus(); // popup focus return, like the chooser + }; + Children.Add(addButton); + } + + // The legacy VwSeparatorBox: a ~2px, font-height, light grey vertical bar after each item + // (and fronting the add slot) — the affordance that marks where content can be added. + private static Control SeparatorBar() => new Border + { + Width = 2, + Height = 14, + Background = Brushes.LightGray, + Margin = new Thickness(2, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center + }; + } } diff --git a/Src/Common/FwAvalonia/Region/IRegionEditContext.cs b/Src/Common/FwAvalonia/Region/IRegionEditContext.cs index 591f5a7a35..480271ac2d 100644 --- a/Src/Common/FwAvalonia/Region/IRegionEditContext.cs +++ b/Src/Common/FwAvalonia/Region/IRegionEditContext.cs @@ -37,6 +37,19 @@ public interface IRegionEditContext /// Stages a chooser selection by option key (opening the session on the first edit). bool TrySetOption(LexicalEditRegionField field, string optionKey); + /// + /// Stages adding an item (by option key) to a + /// row (6.3). Returns false — WITHOUT opening the session — for keys outside the field's + /// possibility list, duplicates, or non-vector rows, like the legacy chooser. + /// + bool TryAddReferenceItem(LexicalEditRegionField field, string optionKey); + + /// + /// Stages removing an item (by option key) from a + /// row. Returns false — without opening the session — when the item is not in the vector. + /// + bool TryRemoveReferenceItem(LexicalEditRegionField field, string optionKey); + /// /// Validates the staged state. Empty result means commit may proceed; messages are /// user-facing (validation seam, deterministic order). diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs index 6bc1f09694..5c39cb3e3c 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs @@ -34,7 +34,15 @@ public enum RegionFieldKind Image, /// A command row rendered as a button (execution rides command routing, shell phase). - Command + Command, + + /// + /// An editable reference vector (6.3/B8): current items plus the possibility list's options + /// (hierarchy on ), edited through + /// / — + /// the legacy possibility-vector slice with its trailing type-ahead add slot. + /// + ReferenceVector } /// @@ -73,14 +81,22 @@ public RegionWsValue(string wsAbbrev, string value, string fontFamily = null, do /// A chooser option (key + display name). public sealed class RegionChoiceOption { - public RegionChoiceOption(string key, string name) + public RegionChoiceOption(string key, string name, int depth = 0) { Key = key; Name = name; + Depth = depth; } public string Key { get; } public string Name { get; } + + /// + /// Hierarchy level for deep possibility lists (B8): 0 for top-level items, +1 per + /// sub-possibility nesting, in the list's own document order — drives the legacy indented + /// chooser tree. Flat lists (and chooserInfo FlatList specs, B7) stay 0 throughout. + /// + public int Depth { get; } } /// @@ -112,8 +128,10 @@ public LexicalEditRegionField( string contextMenuId = null, string hotlinksId = null, int objectHvo = 0, - string ghostPrompt = null) + string ghostPrompt = null, + IReadOnlyList items = null) { + Items = items ?? new List(); GhostPrompt = ghostPrompt; IsEditable = isEditable; Indent = indent; @@ -156,6 +174,12 @@ public LexicalEditRegionField( public IReadOnlyList Options { get; } public string SelectedOptionKey { get; } + /// + /// The CURRENT items of a row, in vector order + /// (key = possibility guid, name = display name). Empty for other kinds. + /// + public IReadOnlyList Items { get; } + /// False for display-only fields (e.g. reference fields without chooser write-back yet). public bool IsEditable { get; } diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs index 6ea8f7bd10..22430ce0de 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs @@ -384,6 +384,8 @@ private Control BuildEditor(LexicalEditRegionField field, string automationId) { switch (field.Kind) { + case RegionFieldKind.ReferenceVector: + return new FwReferenceVectorField(field, automationId, _editContext); case RegionFieldKind.Chooser: return BuildChooser(field, automationId); case RegionFieldKind.Boolean: diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs index ea04fdfec8..1cd64eb728 100644 --- a/Src/xWorks/FullEntryRegionComposer.cs +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -99,11 +99,37 @@ public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool foreach (var node in root.Roots) state.Walk(node, entry, 0); - var context = new ComposedRegionEditContext(cache, entry, state.TextSetters, state.OptionSetters); + var context = new ComposedRegionEditContext(cache, entry, state.TextSetters, state.OptionSetters, + state.ReferenceAddSetters, state.ReferenceRemoveSetters); var model = new LexicalEditRegionModel("LexEntry", "Normal", state.Fields, root.Diagnostics); return new ComposedEntryRegion(model, context, state.CustomEditorFields); } + /// + /// B8/B7: walks a possibility list's tree in document order (parent before children) into + /// chooser options, hierarchy carried as — exactly + /// the indented tree the legacy chooser shows. (a chooserInfo + /// "FlatList" guicontrol spec, e.g. PeopleFlatList) keeps the order but suppresses the + /// hierarchy, like the legacy flat chooser. + /// + internal static IReadOnlyList BuildPossibilityOptions( + ICmPossibilityList list, bool flat) + { + var options = new List(); + void Add(ICmPossibility possibility, int depth) + { + options.Add(new RegionChoiceOption(possibility.Guid.ToString(), + possibility.Name.BestAnalysisAlternative?.Text ?? possibility.ShortName ?? possibility.Guid.ToString(), + flat ? 0 : depth)); + foreach (var sub in possibility.SubPossibilitiesOS) + Add(sub, depth + 1); + } + + foreach (var possibility in list.PossibilitiesOS) + Add(possibility, 0); + return options; + } + private sealed class ComposeState { private readonly LcmCache _cache; @@ -116,6 +142,11 @@ public readonly Dictionary> TextSetters = new Dictionary>(StringComparer.Ordinal); public readonly Dictionary> OptionSetters = new Dictionary>(StringComparer.Ordinal); + // 6.3: reference-vector add/remove staging, keyed like the other setters by StableId. + public readonly Dictionary> ReferenceAddSetters + = new Dictionary>(StringComparer.Ordinal); + public readonly Dictionary> ReferenceRemoveSetters + = new Dictionary>(StringComparer.Ordinal); // Companion lane: the unsupported rows that are really legacy dynamic custom slices, // keyed by the row's StableId (see ComposedEntryRegion.CustomEditorFields). public readonly List CustomEditorFields @@ -704,6 +735,101 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) private static bool TryClassifyMorphType(Guid guid, out MorphTypeKind kind) => MorphTypeKindByGuid.TryGetValue(guid, out kind); + // 6.3: an atomic possibility reference takes the chooser lane (legacy + // PossibilityAtomicReferenceSlice): options from the field's own list + // (ReferenceTargetOwner), write-back through the fenced session. + private void AddAtomicPossibilityChooser(ViewNode node, ICmObject obj, int depth, int flid, + ICmPossibilityList list, int targetHvo) + { + // B7 remainder: chooserInfo FlatList specs are not yet imported onto the node; + // until they are, the chooser renders the list's own hierarchy. + var options = BuildPossibilityOptions(list, flat: false); + var selected = targetHvo == 0 + ? null + : _cache.ServiceLocator.ObjectRepository.GetObject(targetHvo).Guid.ToString(); + var stableId = StableId(node, obj); + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, + node.WritingSystem, RegionFieldKind.Chooser, node.EditorClassification, node.AutomationId, + node.LocalizationKey, node.Routing, null, options, selected, isEditable: true, indent: depth, + menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, + objectHvo: obj.Hvo)); + + var hvo = obj.Hvo; + OptionSetters[stableId] = key => + { + var possibility = ResolvePossibilityInList(list, key); + if (possibility == null) + return false; + _sda.SetObjProp(hvo, flid, possibility.Hvo); + return true; + }; + } + + // 6.3/B8: an editable possibility-vector row — current items in vector order plus the + // whole list as hierarchical options; add/remove stage through sda.Replace on the flid + // (the legacy VectorReferenceView update), one undo step per settled session. + private void AddReferenceVector(ViewNode node, ICmObject obj, int depth, int flid, + ICmPossibilityList list, int count) + { + var items = new List(); + for (var i = 0; i < count; i++) + { + var itemHvo = _sda.get_VecItem(obj.Hvo, flid, i); + var item = _cache.ServiceLocator.ObjectRepository.GetObject(itemHvo); + items.Add(new RegionChoiceOption(item.Guid.ToString(), ResolveShortName(itemHvo))); + } + + var options = BuildPossibilityOptions(list, flat: false); // B7 remainder, see above + var stableId = StableId(node, obj); + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, + node.WritingSystem, RegionFieldKind.ReferenceVector, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, null, options, null, + isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, + hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items)); + + var hvo = obj.Hvo; + ReferenceAddSetters[stableId] = key => + { + var possibility = ResolvePossibilityInList(list, key); + if (possibility == null) + return false; + var size = _sda.get_VecSize(hvo, flid); + for (var i = 0; i < size; i++) + { + if (_sda.get_VecItem(hvo, flid, i) == possibility.Hvo) + return false; // duplicates rejected, like the legacy chooser + } + _sda.Replace(hvo, flid, size, size, new[] { possibility.Hvo }, 1); + return true; + }; + ReferenceRemoveSetters[stableId] = key => + { + var possibility = ResolvePossibilityInList(list, key); + if (possibility == null) + return false; + var size = _sda.get_VecSize(hvo, flid); + for (var i = 0; i < size; i++) + { + if (_sda.get_VecItem(hvo, flid, i) != possibility.Hvo) + continue; + _sda.Replace(hvo, flid, i, i + 1, new int[0], 0); + return true; + } + return false; + }; + } + + // Resolves an option key to a possibility belonging to THIS field's list — garbage, + // unknown guids, and possibilities from OTHER lists all reject (no cross-list writes). + private ICmPossibility ResolvePossibilityInList(ICmPossibilityList list, string key) + { + if (!Guid.TryParse(key, out var guid)) + return null; + if (!_cache.ServiceLocator.GetInstance().TryGetObject(guid, out var possibility)) + return null; + return possibility.OwningList == list ? possibility : null; + } + // Viewing parity (11.x): every field type the legacy slices display has a rendering here: // booleans as checkboxes (editable), integers editable, dates/gendates formatted, // structured text as paragraph text, references as value rows; explicit unsupported rows @@ -719,6 +845,17 @@ private void WalkOtherField(ViewNode node, ICmObject obj, int depth) case CellarPropertyType.ReferenceAtomic: { var targetHvo = _sda.get_ObjectProp(obj.Hvo, flid); + + // 6.3: an atomic ref whose target owner is a possibility list takes the + // chooser lane (legacy PossibilityAtomicReferenceSlice), like morph type. + if (obj.ReferenceTargetOwner(flid) is ICmPossibilityList list) + { + if (targetHvo == 0 && HideWhenEmpty(node)) + return; + AddAtomicPossibilityChooser(node, obj, depth, flid, list, targetHvo); + return; + } + if (targetHvo == 0) { AddRowUnlessHiddenWhenEmpty(node, obj, depth); @@ -756,6 +893,19 @@ private void WalkOtherField(ViewNode node, ICmObject obj, int depth) case CellarPropertyType.ReferenceCollection: { var count = _sda.get_VecSize(obj.Hvo, flid); + + // 6.3/B8: a vector whose targets live in a possibility list becomes an + // editable ReferenceVector row (the legacy possibility-vector slice with + // its trailing type-ahead add slot) — even when empty, so an always-visible + // field still offers the add affordance. + if (obj.ReferenceTargetOwner(flid) is ICmPossibilityList list) + { + if (count == 0 && HideWhenEmpty(node)) + return; + AddReferenceVector(node, obj, depth, flid, list, count); + return; + } + if (count == 0) { AddRowUnlessHiddenWhenEmpty(node, obj, depth); @@ -1316,32 +1466,63 @@ public sealed class ComposedRegionEditContext : RegionEditContextBase { private readonly IReadOnlyDictionary> _textSetters; private readonly IReadOnlyDictionary> _optionSetters; + private readonly IReadOnlyDictionary> _referenceAddSetters; + private readonly IReadOnlyDictionary> _referenceRemoveSetters; public ComposedRegionEditContext( LcmCache cache, ILexEntry entry, IReadOnlyDictionary> textSetters, - IReadOnlyDictionary> optionSetters) + IReadOnlyDictionary> optionSetters, + IReadOnlyDictionary> referenceAddSetters = null, + IReadOnlyDictionary> referenceRemoveSetters = null) : base(cache, entry) { _textSetters = textSetters; _optionSetters = optionSetters; + _referenceAddSetters = referenceAddSetters ?? new Dictionary>(); + _referenceRemoveSetters = referenceRemoveSetters ?? new Dictionary>(); } public override bool TrySetText(LexicalEditRegionField field, string ws, string value) { if (field == null || !_textSetters.TryGetValue(field.StableId, out var setter)) return false; - EnsureOpen(); - return setter(ws, value); + return Stage(() => setter(ws, value)); } public override bool TrySetOption(LexicalEditRegionField field, string optionKey) { if (field == null || !_optionSetters.TryGetValue(field.StableId, out var setter)) return false; + return Stage(() => setter(optionKey)); + } + + public override bool TryAddReferenceItem(LexicalEditRegionField field, string optionKey) + { + if (field == null || !_referenceAddSetters.TryGetValue(field.StableId, out var setter)) + return false; + return Stage(() => setter(optionKey)); + } + + public override bool TryRemoveReferenceItem(LexicalEditRegionField field, string optionKey) + { + if (field == null || !_referenceRemoveSetters.TryGetValue(field.StableId, out var setter)) + return false; + return Stage(() => setter(optionKey)); + } + + // Setters must run inside the fenced session, but a REJECTED edit must not leave an empty + // fence open (it would hold the UOW write lock and gate refreshes). If this call opened the + // session and staged nothing, close it again. + private bool Stage(Func setter) + { + var wasOpen = IsOpen; EnsureOpen(); - return setter(optionKey); + var staged = setter(); + if (!staged && !wasOpen) + Cancel(); + return staged; } } } diff --git a/Src/xWorks/RegionEditContextBase.cs b/Src/xWorks/RegionEditContextBase.cs index 22db2a0dd1..217be25e43 100644 --- a/Src/xWorks/RegionEditContextBase.cs +++ b/Src/xWorks/RegionEditContextBase.cs @@ -40,6 +40,14 @@ protected RegionEditContextBase(LcmCache cache, ILexEntry entry) /// public abstract bool TrySetOption(LexicalEditRegionField regionField, string optionKey); + /// + /// Reference-vector editing (6.3) exists only on composed regions; the first-slice + /// fallback has no vector rows, so the base rejects. + public virtual bool TryAddReferenceItem(LexicalEditRegionField regionField, string optionKey) => false; + + /// + public virtual bool TryRemoveReferenceItem(LexicalEditRegionField regionField, string optionKey) => false; + /// public IReadOnlyList Validate() { diff --git a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs index 0137384908..e40cf9fdc4 100644 --- a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs +++ b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs @@ -1083,11 +1083,14 @@ public void Compose_CustomFields_ExpandAtThePlaceholder_WithLegacyLabelsAndValue Assert.That(date.IsEditable, Is.False, "GenDate stays read-only (matches existing GenDate rows)"); Assert.That(date.Values.Single().Value, Is.EqualTo(m_genDate.ToLongString())); - // Possibility-list reference: read-only joined name for now (chooser write-back rides 6.3). + // Possibility-list reference: the 6.3 chooser lane makes custom reference rows + // editable too — options from the field's own list, current selection carried. var listRef = fields.FirstOrDefault(f => f.Label == "Field Category"); Assert.That(listRef, Is.Not.Null); - Assert.That(listRef.IsEditable, Is.False, "reference write-back is deferred to the 6.3 chooser lane"); - Assert.That(listRef.Values.Single().Value, Is.EqualTo(m_listItem.ShortName)); + Assert.That(listRef.IsEditable, Is.True, "custom possibility references take the 6.3 chooser lane"); + Assert.That(listRef.SelectedOptionKey, Is.EqualTo(m_listItem.Guid.ToString())); + Assert.That(listRef.Options.Select(o => o.Key), Does.Contain(m_listItem.Guid.ToString()), + "options come from the custom field's own possibility list"); // Integer: editable like the existing int rows. var number = fields.FirstOrDefault(f => f.Label == "Frequency Count"); diff --git a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md index a26755ffde..95245aa047 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md +++ b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md @@ -218,6 +218,14 @@ change's surfaces. - **Retirement risk if unaddressed:** Medium-high. Choosers open with wrong/missing titles, guidance text, and jump links; reference-field workflows degrade. Blocks 9.4 for surfaces whose fields are chooser-driven (most reference fields). +- **Status (6.3 landed):** reference write-back is live — atomic possibility references take + the chooser lane (options from `ReferenceTargetOwner`, cross-list writes rejected) and + possibility vectors compose as editable `ReferenceVector` rows (add/remove through the + fenced session, duplicate/garbage/unknown keys rejected without opening it), covered by + `FullEntryRegionReferenceChooserTests`. `BuildPossibilityOptions(flat:)` implements the + FlatList guicontrol semantics; REMAINING: import `chooserInfo`/`chooserLink` onto the typed + node and thread the flat/title/link specs to the composer call sites (the composer currently + passes `flat: false`). ### B8. TreeView-heavy views @@ -232,6 +240,11 @@ change's surfaces. - **Retirement risk if unaddressed:** Medium. Large semantic-domain/possibility trees freeze or scroll badly; accessibility tree explodes. Gates choosers (B7) and any tree-shaped surface's 9.4. +- **Status (6.3/B8 landed):** the 7.6 recommendation is implemented for chooser trees — + hierarchy is flattened in the model (`RegionChoiceOption.Depth`, document order, pinned by + tests) and rendered as an indented, virtualized `ListBox` flyout (`FwReferenceVectorField`), + not a stock `TreeView`, so deep lists (semantic domains) stay scalable. The record-bar tree + and 6.4 sense/term trees remain on the 7.6/6.4 lanes. ### B9. Parameter substitution (`$ws`, `$fieldName`, `{0}`, `param`) From bd2867cfc1abc581dd9c516147e5dd25abf3448b Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 16:37:50 -0400 Subject: [PATCH 05/14] Round 2 of alignment and no WinForms in Lexical Edit --- Directory.Packages.props | 9 + Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 21 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 27 ++ .../ChorusNotesBarControlTests.cs | 249 ++++++++++ .../DialogLauncherFieldTests.cs | 86 ++++ .../RegionCustomFieldRenderingTests.cs | 98 ++++ .../FwAvaloniaTests/RegionEditingTests.cs | 83 +++- .../Region/ChorusNotesBarControl.cs | 377 +++++++++++++++ .../FwAvalonia/Region/FwFieldControls.cs | 90 +++- .../Region/LexicalEditRegionModel.cs | 38 +- .../Region/LexicalEditRegionView.cs | 38 ++ Src/xWorks/AvaloniaCompanionSlices.cs | 30 +- Src/xWorks/ChorusNotesPlugin.cs | 429 ++++++++++++++++++ Src/xWorks/DialogLauncherPlugins.cs | 188 ++++++++ Src/xWorks/FullEntryRegionComposer.cs | 277 ++++++++++- Src/xWorks/LegacyDialogLauncher.cs | 266 +++++++++++ Src/xWorks/RecordEditView.cs | 29 +- Src/xWorks/RegionEditorPlugins.cs | 193 ++++++++ Src/xWorks/xWorks.csproj | 15 +- .../xWorksTests/ChorusNotesContractTests.cs | 323 +++++++++++++ .../xWorksTests/DialogLauncherPluginTests.cs | 270 +++++++++++ .../xWorksTests/EntryReferenceVectorTests.cs | 222 +++++++++ .../xWorksTests/LexemeEditorBurnDownTests.cs | 357 +++++++++++++++ .../xWorksTests/MessagesCompanionLaneTests.cs | 89 ++-- Src/xWorks/xWorksTests/xWorksTests.csproj | 11 +- .../chorus-notes-contract.md | 205 +++++++++ .../winforms-free-lexeme-editor.md | 200 ++++++++ .../xml-retirement-blockers.md | 14 +- 28 files changed, 4183 insertions(+), 51 deletions(-) create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ChorusNotesBarControlTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DialogLauncherFieldTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/RegionCustomFieldRenderingTests.cs create mode 100644 Src/Common/FwAvalonia/Region/ChorusNotesBarControl.cs create mode 100644 Src/xWorks/ChorusNotesPlugin.cs create mode 100644 Src/xWorks/DialogLauncherPlugins.cs create mode 100644 Src/xWorks/LegacyDialogLauncher.cs create mode 100644 Src/xWorks/RegionEditorPlugins.cs create mode 100644 Src/xWorks/xWorksTests/ChorusNotesContractTests.cs create mode 100644 Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs create mode 100644 Src/xWorks/xWorksTests/EntryReferenceVectorTests.cs create mode 100644 Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs create mode 100644 openspec/changes/lexical-edit-avalonia-migration/chorus-notes-contract.md create mode 100644 openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md diff --git a/Directory.Packages.props b/Directory.Packages.props index 066dc2e97a..68a6b653c9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -190,10 +190,19 @@ (targets net461) for the in-process embedding evaluated by the spike. These are referenced only by the isolated Src/Common/FwAvalonia spike projects and are not part of the default product packaging. + Exception: xWorks and xWorksTests carry a compile-only (PrivateAssets) + reference to the base Avalonia package so the IRegionEditorPlugin + contract (winforms-free-lexeme-editor.md D1) can type its control + factories; the runtime assemblies still ship with FwAvalonia. ============================================================= --> + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index 25ef417d4c..247cd5c922 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -45,5 +45,26 @@ public static class FwAvaloniaStrings /// "Add item" — reference-vector add-slot launcher name (6.3). public static string AddItem => Resources.GetString("ksAddItem"); + + /// "Type to search" — the search-backed add slot's type-ahead watermark (D3). + public static string SearchPrompt => Resources.GetString("ksSearchPrompt"); + + /// "Add note" — the Chorus notes bar's add affordance (D2). + public static string ChorusAddNote => Resources.GetString("ksChorusAddNote"); + + /// "Add message" — append-message watermark in a Chorus note flyout (D2). + public static string ChorusAddMessage => Resources.GetString("ksChorusAddMessage"); + + /// "OK" — confirm button of the Chorus notes flyouts (D2). + public static string ChorusOk => Resources.GetString("ksChorusOk"); + + /// "Resolved" — the resolve toggle of a Chorus note flyout (D2). + public static string ChorusResolved => Resources.GetString("ksChorusResolved"); + + /// Accessible name of the "..." dialog-launcher button (D4). + public static string LaunchDialog => Resources.GetString("ksLaunchDialog"); + + /// Tooltip of a disabled launcher button: no host dialog service (D4). + public static string LauncherUnavailable => Resources.GetString("ksLauncherUnavailable"); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index 40bc34771e..53a34a96d3 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -66,4 +66,31 @@ Add item + + Type to search + + + Add note + Add-note affordance of the Chorus Send/Receive notes bar (D2). + + + Add message + Watermark of the append-message editor in a Chorus note's flyout. + + + OK + Confirm button of the Chorus notes flyouts. + + + Resolved + Resolve (close/reopen) toggle of a Chorus note's flyout. + + + Edit in dialog + Accessible name of the "..." dialog-launcher button on a launcher row (D4). + + + This field is edited in a dialog that is not available in this view. + Tooltip of the disabled launcher button when the host provides no dialog service (D4). + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/ChorusNotesBarControlTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/ChorusNotesBarControlTests.cs new file mode 100644 index 0000000000..981d948fea --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/ChorusNotesBarControlTests.cs @@ -0,0 +1,249 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.LogicalTree; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; + +namespace FwAvaloniaTests +{ + /// + /// winforms-free-lexeme-editor.md D2 — headless behavior of the Avalonia Chorus notes bar + /// () over a fake : notes + /// render as the icon strip, add-note writes through the store, blank notes are discarded, the + /// resolve affordance follows CanResolve, and an external store change (NotifyOfStaleList after + /// S/R) re-renders. The LibChorus file/ref compatibility half lives in xWorksTests + /// (ChorusNotesContractTests) against the real repositories. + /// + [TestFixture] + public class ChorusNotesBarControlTests + { + private sealed class FakeNoteItem : IChorusNoteItem + { + public readonly List AppendedMessages = new List(); + public int ResolveToggles; + + public string ClassName { get; set; } = "question"; + public string Label { get; set; } = "casa"; + public bool IsClosed { get; set; } + public bool CanResolve { get; set; } = true; + public string Tooltip { get; set; } = "question: casa"; + public IReadOnlyList Messages { get; set; } = + new List { new ChorusNoteMessage("who", DateTime.Now, "", "Is this right?") }; + + public bool AppendMessage(string text) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + AppendedMessages.Add(text); + return true; + } + + public bool ToggleResolved() + { + if (!CanResolve) + return false; + ResolveToggles++; + IsClosed = !IsClosed; + return true; + } + } + + private sealed class FakeNotesBarModel : IChorusNotesBarModel + { + public readonly List Notes = new List(); + public readonly List AddedNotes = new List(); + public int MessageEditorFocusedCalls; + + public IReadOnlyList GetNotes() => Notes.ToList(); + + public bool AddNote(string text) + { + // The store's contract (§5.2): blank ⇒ discarded, nothing written. + if (string.IsNullOrWhiteSpace(text)) + return false; + AddedNotes.Add(text); + Notes.Add(new FakeNoteItem { Tooltip = "question: " + text }); + return true; + } + + public string LabelFontFamily => "Arial"; + public double LabelFontSize => 12; + public string MessageFontFamily => "Arial"; + public double MessageFontSize => 12; + + public void MessageEditorFocused() => MessageEditorFocusedCalls++; + + public event EventHandler NotesChanged; + + public void RaiseNotesChanged() => NotesChanged?.Invoke(this, EventArgs.Empty); + } + + private static (ChorusNotesBarControl bar, FakeNotesBarModel model, Window window) Show( + params FakeNoteItem[] notes) + { + var model = new FakeNotesBarModel(); + model.Notes.AddRange(notes); + var bar = new ChorusNotesBarControl(model); + var window = new Window { Content = bar, Width = 400, Height = 120 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (bar, model, window); + } + + private static IReadOnlyList - ReferenceVector + ReferenceVector, + + /// + /// A plugin-claimed custom editor (winforms-free-lexeme-editor.md D1): the row carries a + /// built by the composer from the + /// claiming IRegionEditorPlugin; the view renders the factory's control in the value + /// column at the slice's real position, falling back to the unsupported rendering when the + /// factory is missing or fails. + /// + Custom } /// @@ -129,9 +140,13 @@ public LexicalEditRegionField( string hotlinksId = null, int objectHvo = 0, string ghostPrompt = null, - IReadOnlyList items = null) + IReadOnlyList items = null, + Func controlFactory = null, + Func> searchOptions = null) { Items = items ?? new List(); + ControlFactory = controlFactory; + SearchOptions = searchOptions; GhostPrompt = ghostPrompt; IsEditable = isEditable; Indent = indent; @@ -203,6 +218,25 @@ public LexicalEditRegionField( /// The LCModel object this row is bound to (command-target context for menus). public int ObjectHvo { get; } + + /// + /// For a row (winforms-free-lexeme-editor.md D1): the + /// deferred control factory the claiming plugin supplied via the composer. The view invokes + /// it at render time and places the returned control in the value column; null (or a + /// failing factory) renders the unsupported row instead. Null for every other kind. + /// + public Func ControlFactory { get; } + + /// + /// For a row whose targets are searched rather + /// than enumerated (winforms-free-lexeme-editor.md D3 — possibility lists enumerate, lexicons + /// search): a type-ahead search delegate the composer supplied (e.g. a headword-prefix search + /// over the entry repository). When non-null the add slot opens a search flyout instead of the + /// full list; selecting a result stages through + /// with the result's key. Like + /// , a plain delegate keeps this layer LCModel-free. + /// + public Func> SearchOptions { get; } } /// Which legacy menu lane a right-click maps to (section 13). diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs index 22430ce0de..d679bc71eb 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs @@ -384,6 +384,8 @@ private Control BuildEditor(LexicalEditRegionField field, string automationId) { switch (field.Kind) { + case RegionFieldKind.Custom: + return BuildCustom(field, automationId); case RegionFieldKind.ReferenceVector: return new FwReferenceVectorField(field, automationId, _editContext); case RegionFieldKind.Chooser: @@ -477,6 +479,42 @@ private Control BuildText(LexicalEditRegionField field, string automationId) private Control BuildChooser(LexicalEditRegionField field, string automationId) => new FwChooserField(field, automationId, _editContext); + // winforms-free-lexeme-editor.md D1: a plugin-claimed custom slice renders its plugin's own + // Avalonia control in the value column, at the slice's real position. Guarded lane: a + // missing, null-returning, or throwing factory degrades to the explicit unsupported row — + // never a crash, never a silently blank row. + private static Control BuildCustom(LexicalEditRegionField field, string automationId) + { + if (field.ControlFactory == null) + { + System.Diagnostics.Debug.WriteLine( + $"Custom region field '{field.StableId}' has no control factory; rendering the unsupported row."); + return BuildUnsupported(field, automationId); + } + + try + { + var control = field.ControlFactory(); + if (control == null) + { + System.Diagnostics.Debug.WriteLine( + $"Custom region field '{field.StableId}' factory returned null; rendering the unsupported row."); + return BuildUnsupported(field, automationId); + } + + // Plugins may carry their own automation identity; only fill in the row's when absent. + if (string.IsNullOrEmpty(AutomationProperties.GetAutomationId(control))) + AutomationProperties.SetAutomationId(control, automationId); + return control; + } + catch (Exception e) + { + System.Diagnostics.Debug.WriteLine( + $"Custom region field '{field.StableId}' factory threw; rendering the unsupported row: {e}"); + return BuildUnsupported(field, automationId); + } + } + private static Control BuildUnsupported(LexicalEditRegionField field, string automationId) { var block = new TextBlock diff --git a/Src/xWorks/AvaloniaCompanionSlices.cs b/Src/xWorks/AvaloniaCompanionSlices.cs index a53c7414c6..6f594337a9 100644 --- a/Src/xWorks/AvaloniaCompanionSlices.cs +++ b/Src/xWorks/AvaloniaCompanionSlices.cs @@ -63,23 +63,39 @@ public static class AvaloniaCompanionSlices /// The Chorus Send/Receive notes bar (LexEntryParts.xml part "LexEntry-Detail-Messages"). public const string MessageSliceClassName = "SIL.FieldWorks.XWorks.LexEd.MessageSlice"; - // The designated companion classes. Every other dynamic custom editor keeps the explicit - // unsupported row until it gets a real Avalonia mapping (blocker register B11). - private static readonly HashSet PromotedClassNames = new HashSet(StringComparer.Ordinal) - { - MessageSliceClassName - }; + // The designated companion classes. EMPTY since wave 2 (winforms-free-lexeme-editor.md D2): + // the Messages slice — the lane's only designated class — graduated to the native + // ChorusNotesPlugin. The mechanism stays: it is the documented coexistence lane for future + // tools' WinForms-only custom slices (blocker register B11); with an empty set the + // RecordEditView companion strip simply never shows. + private static readonly HashSet PromotedClassNames = new HashSet(StringComparer.Ordinal); + + /// + /// Read-only view of the designated companion classes for the burn-down governance lane + /// (winforms-free-lexeme-editor.md D5): this set may only SHRINK — a class graduates + /// unsupported → companion → plugin, never the other way. Pinned by + /// LexemeEditorBurnDownTests; wave 2 (ChorusNotesPlugin, D2) emptied it. + /// + public static IReadOnlyCollection DesignatedClassNames => PromotedClassNames; /// /// Picks the composed custom-editor fields that are designated for companion-strip promotion. /// public static IReadOnlyList SelectPromotions( IReadOnlyList customEditorFields) + { + return SelectPromotions(customEditorFields, PromotedClassNames); + } + + // Testable seam: the designated set is empty since wave 2, so the mechanism's selection + // tests inject a fake designated class (MessagesCompanionLaneTests). + internal static IReadOnlyList SelectPromotions( + IReadOnlyList customEditorFields, ISet designatedClassNames) { if (customEditorFields == null || customEditorFields.Count == 0) return Array.Empty(); return customEditorFields - .Where(f => f != null && PromotedClassNames.Contains(f.ClassName)) + .Where(f => f != null && designatedClassNames.Contains(f.ClassName)) .ToList(); } diff --git a/Src/xWorks/ChorusNotesPlugin.cs b/Src/xWorks/ChorusNotesPlugin.cs new file mode 100644 index 0000000000..287460b0ad --- /dev/null +++ b/Src/xWorks/ChorusNotesPlugin.cs @@ -0,0 +1,429 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using Avalonia.Controls; +using Chorus.notes; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.Progress; +using SIL.Reporting; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D2 / chorus-notes-contract.md — the UI-free core of the + /// Avalonia Chorus notes bar: owns the LibChorus repository wiring the legacy + /// ChorusSystem.WinForms.CreateNotesBar did (contract §3), and every read/write goes + /// through the same files, ref-URL/key shapes, and canonical save lane legacy `MessageSlice` + /// used — FLExBridge/S&R and the legacy bar see ONE notes store. No UI types here; the + /// Avalonia pixels live in , bridged by + /// . Pinned by ChorusNotesContractTests (contract §8). + /// + public sealed class ChorusNotesStore : IDisposable + { + /// The dummy "file being annotated" (FLExBridgeListener.FakeLexiconFileName, §1). + public const string StubFileName = "Lexicon.fwstub"; + + /// AnnotationRepository.FileExtension / FLExBridgeListener.kChorusNotesExtension (§1). + public const string ChorusNotesExtension = "ChorusNotes"; + + /// The primary notes file new notes land in (§1). + public const string PrimaryNotesFileName = StubFileName + "." + ChorusNotesExtension; + + /// New FLEx notes are always class "question" (§5.1). + public const string NewNoteClass = "question"; + + /// The exact single line legacy MessageSlice writes into a missing stub (§1). + public const string StubFileContent = + "This is a stub file to provide an attachment point for " + PrimaryNotesFileName; + + private readonly AnnotationRepository _primary; + private readonly List _additional = new List(); + private readonly MultiSourceAnnotationRepository _all; + private readonly StoreObserver _observer; + private bool _disposed; + + /// + /// Opens (creating as needed, §1) the project's lexicon notes store: the primary repository + /// on Lexicon.fwstub.ChorusNotes keyed by the id ref parameter, plus one + /// guid-keyed repository per Linguistics/Lexicon/*.lexdb whose + /// .ChorusNotes already exists (FLExBridge conflict notes), wrapped multi-source so + /// new notes always go to the primary (§3). + /// + public ChorusNotesStore(string projectFolder) + { + if (string.IsNullOrEmpty(projectFolder)) + throw new ArgumentNullException(nameof(projectFolder)); + + var progress = new NullProgress(); + EnsureStubFile(projectFolder); + PrimaryNotesFilePath = Path.Combine(projectFolder, PrimaryNotesFileName); + // The primary ref parameter is hard-coded "id" (§3.1); FromFile creates a missing file + // containing exactly (§1). + _primary = AnnotationRepository.FromFile("id", PrimaryNotesFilePath, progress); + foreach (var lexdbPath in GetAdditionalLexiconFilePaths(projectFolder)) + { + // .lexdb chorus notes files identify the FLEx object with a url attr of "guid" (§3.2). + _additional.Add(AnnotationRepository.FromFile("guid", + lexdbPath + "." + ChorusNotesExtension, progress)); + } + _all = new MultiSourceAnnotationRepository(_primary, _additional.Cast()); + + // External refresh (§6): each repository owns a FileSystemWatcher on its file and raises + // NotifyOfStaleList on external change (e.g. after S/R); observing surfaces that as + // NotesChanged — no legacy 500 ms polling timer. + _observer = new StoreObserver(this); + _primary.AddObserver(_observer, progress); + foreach (var repository in _additional) + repository.AddObserver(_observer, progress); + } + + /// Full path of Lexicon.fwstub.ChorusNotes. + public string PrimaryNotesFilePath { get; } + + /// + /// The backing files changed (addition, modification, deletion, or a stale list after an + /// external write such as Send/Receive). Raised on whatever thread the repository observer + /// fires on — UI consumers marshal (§6). + /// + public event EventHandler NotesChanged; + + private void RaiseNotesChanged() => NotesChanged?.Invoke(this, EventArgs.Empty); + + /// + /// The silfw link new FLEx lexicon notes carry (contract §4, verbatim template from + /// MessageSlice.cs:124-130): the entry guid appears twice — guid= drives FLEx + /// jump-navigation, id= is what the primary index matches — and the label + /// (ShortName/headword) is the LAST parameter. + /// + public static string BuildRefUrl(string entryGuid, string label) + { + return string.Format( + "silfw://localhost/link?app=flex&database=current&server=&tool=default&guid={0}&tag=&id={0}&label={1}", + entryGuid, label); + } + + /// + /// The annotations to show for a target: for each key (the entry's lowercase guid plus its + /// AllOwnedObjects guids — notes attached to senses/allomorphs show on the entry's bar), + /// the matches across primary and lexdb repositories, concatenated. Open AND closed — the + /// legacy bar never filtered (§3.4/§3.5). + /// + public IReadOnlyList GetAnnotationsFor(string targetId, IEnumerable additionalIds = null) + { + var keys = new List(); + if (!string.IsNullOrEmpty(targetId)) + keys.Add(targetId); + if (additionalIds != null) + keys.AddRange(additionalIds.Where(id => !string.IsNullOrEmpty(id))); + return keys.SelectMany(key => _all.GetMatchesByPrimaryRefKey(key)).ToList(); + } + + /// "ifdata" visibility parity: any annotation for the key in the PRIMARY file only (§6). + public bool HasPrimaryNotes(string targetId) + { + return !string.IsNullOrEmpty(targetId) && _primary.GetMatchesByPrimaryRefKey(targetId).Any(); + } + + /// + /// Writes a new note (§5): class "question", the §4 silfw ref, first message authored by + /// (SendReceiveUser) with status "" — added to the + /// PRIMARY repository and flushed immediately. A blank text is discarded: returns null, + /// nothing written (the legacy dialog's cancel/empty path). + /// + public Annotation AddNote(string entryGuid, string label, string text) + { + if (string.IsNullOrWhiteSpace(text)) + return null; + // The path placeholder is irrelevant — the repository assigns it on add (§5.1). + var annotation = new Annotation(NewNoteClass, BuildRefUrl(entryGuid, label), "doesntmakesense"); + annotation.AddMessage(Environment.UserName, null, text); // null status inherits "" (§5.4) + _all.AddAnnotation(annotation); // multi-source routes new notes to the primary (§3.3) + _primary.SaveNowIfNeeded(new NullProgress()); // immediate flush (§5.2) + return annotation; + } + + /// Appends a message (current user, status inherited) and saves. False when blank. + public bool AppendMessage(Annotation annotation, string text) + { + if (annotation == null || string.IsNullOrWhiteSpace(text)) + return false; + annotation.AddMessage(Environment.UserName, null, text); + SaveOwnerOf(annotation); + return true; + } + + /// + /// Toggles resolved (§5.4): SetStatus appends an EMPTY message carrying "closed"/"open"; + /// IsClosed derives from the last message. Refused (false, nothing changes) when + /// is false — we defer to the SHIPPED library's + /// exclusion list ("note"/"conflict" in 6.0.0-beta; see contract §5.7), exactly like the + /// legacy bar built on the same assembly. + /// + public bool ToggleResolved(Annotation annotation) + { + if (annotation == null || !annotation.CanResolve) + return false; + annotation.SetStatus(Environment.UserName, + annotation.IsClosed ? Annotation.Open : Annotation.Closed); + SaveOwnerOf(annotation); + return true; + } + + // Always write through the repository's canonical save lane (under the saving mutex), never + // the file directly (§4). Save unconditionally: an in-place message append must reach disk + // even if this LibChorus build's dirty flag misses it. + private void SaveOwnerOf(Annotation annotation) + { + var progress = new NullProgress(); + if (_primary.ContainsAnnotation(annotation)) + { + _primary.Save(progress); + return; + } + var owner = _additional.FirstOrDefault(r => r.ContainsAnnotation(annotation)); + owner?.Save(progress); + } + + // Stub creation, replicated from MessageSlice.cs:86-98 (§1): UTF-8 (BOM) with the single + // content line; deliberately NOT sent/received — only the .ChorusNotes is. Never rewritten + // when present. + private static void EnsureStubFile(string projectFolder) + { + var stubPath = Path.Combine(projectFolder, StubFileName); + if (File.Exists(stubPath)) + return; + using (var writer = new StreamWriter(stubPath, false, Encoding.UTF8)) + writer.WriteLine(StubFileContent); + } + + // Mirrors MessageSlice.GetAdditionalLexiconFilePaths (§1): every Linguistics/Lexicon/*.lexdb + // whose .ChorusNotes already exists (the existence check doubles as the legacy perf guard). + private static IEnumerable GetAdditionalLexiconFilePaths(string projectFolder) + { + var lexiconFolder = Path.Combine(projectFolder, "Linguistics", "Lexicon"); + if (!Directory.Exists(lexiconFolder)) + yield break; + foreach (var path in Directory.EnumerateFiles(lexiconFolder, "*.lexdb")) + { + if (File.Exists(path + "." + ChorusNotesExtension)) + yield return path; + } + } + + /// Dispose order per §6: unhook, then dispose — Dispose performs the final SaveNowIfNeeded. + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + _primary.RemoveObserver(_observer); + foreach (var repository in _additional) + repository.RemoveObserver(_observer); + _primary.Dispose(); + foreach (var repository in _additional) + repository.Dispose(); + } + + private sealed class StoreObserver : IAnnotationRepositoryObserver + { + private readonly ChorusNotesStore _store; + + public StoreObserver(ChorusNotesStore store) + { + _store = store; + } + + public void Initialize(Func> allAnnotationsFunction, IProgress progress) + { + } + + public void NotifyOfAddition(Annotation annotation) => _store.RaiseNotesChanged(); + + public void NotifyOfModification(Annotation annotation) => _store.RaiseNotesChanged(); + + public void NotifyOfDeletion(Annotation annotation) => _store.RaiseNotesChanged(); + + public void NotifyOfStaleList() => _store.RaiseNotesChanged(); + } + } + + /// + /// Projects for ONE entry as the UI-free model the Avalonia bar + /// renders (): keys are lowercase guids — the entry's own and + /// every AllOwnedObjects guid (§3.4) — the new-note label is the entry's ShortName (§4), and + /// the fonts/keyboard follow FWNX-1239 (§7: labels in the default vernacular font, messages in + /// the default analysis font at size 12, analysis keyboard on message-editor focus). + /// + public sealed class ChorusNotesEntryModel : IChorusNotesBarModel + { + private readonly ChorusNotesStore _store; + private readonly ICmObject _target; + private readonly LcmCache _cache; + private readonly string _analysisWsTag; + + public ChorusNotesEntryModel(ChorusNotesStore store, ICmObject target, LcmCache cache) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _target = target ?? throw new ArgumentNullException(nameof(target)); + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + + var writingSystems = cache.ServiceLocator.WritingSystems; + var vernacular = writingSystems.DefaultVernacularWritingSystem; + var analysis = writingSystems.DefaultAnalysisWritingSystem; + LabelFontFamily = vernacular?.DefaultFontName; + MessageFontFamily = analysis?.DefaultFontName; + _analysisWsTag = analysis?.Id; + } + + /// Target id = lowercase entry guid (MessageSlice.GetIdForObject, §3.4). + public static string GetIdForObject(ICmObject obj) + { + return obj.Guid.ToString().ToLowerInvariant(); + } + + /// + /// AllOwnedObjects guids, lowercased (MessageSlice.GetAdditionalIdsForObject, §3.4) — notes + /// attached to senses/allomorphs of the entry show on the entry's bar. + /// + public static IEnumerable GetAdditionalIdsForObject(ICmObject obj) + { + return obj.AllOwnedObjects.Select(t => t.Guid.ToString().ToLowerInvariant()); + } + + /// New-note label = ShortName (the headword), the silfw ref's last parameter (§4). + public static string GetLabelForObject(ICmObject obj) + { + return obj.ShortName; + } + + public IReadOnlyList GetNotes() + { + return _store.GetAnnotationsFor(GetIdForObject(_target), GetAdditionalIdsForObject(_target)) + .Select(annotation => (IChorusNoteItem)new NoteItem(_store, annotation)) + .ToList(); + } + + public bool AddNote(string text) + { + return _store.AddNote(GetIdForObject(_target), GetLabelForObject(_target), text) != null; + } + + // §7 (FWNX-1239): label/headword rendering uses the default vernacular font, message + // display/entry the default analysis font; legacy pinned both at size 12. + public string LabelFontFamily { get; } + + public double LabelFontSize => 12; + + public string MessageFontFamily { get; } + + public double MessageFontSize => 12; + + public void MessageEditorFocused() + { + // §7c: switch the keyboard to the analysis WS — the same lane the region's own editors + // use for per-WS keyboards. + if (!string.IsNullOrEmpty(_analysisWsTag)) + LexicalEditRegionBuilder.ActivateKeyboardForWritingSystem(_cache, _analysisWsTag); + } + + public event EventHandler NotesChanged + { + add => _store.NotesChanged += value; + remove => _store.NotesChanged -= value; + } + + private sealed class NoteItem : IChorusNoteItem + { + private readonly ChorusNotesStore _store; + private readonly Annotation _annotation; + + public NoteItem(ChorusNotesStore store, Annotation annotation) + { + _store = store; + _annotation = annotation; + } + + public string ClassName => _annotation.ClassName; + + public string Label => _annotation.GetLabelFromRef(string.Empty); + + public bool IsClosed => _annotation.IsClosed; + + public bool CanResolve => _annotation.CanResolve; + + // §5.6: class + ": " + label of the thing annotated, then the message texts. + public string Tooltip + { + get + { + var builder = new StringBuilder(); + builder.Append(_annotation.ClassName).Append(": ").Append(_annotation.LabelOfThingAnnotated); + foreach (var message in _annotation.Messages) + { + if (!string.IsNullOrEmpty(message.Text)) + builder.AppendLine().Append(message.Text); + } + return builder.ToString(); + } + } + + public IReadOnlyList Messages + { + get + { + return _annotation.Messages + .Select(m => new ChorusNoteMessage(m.Author, m.Date, m.Status, m.Text)) + .ToList(); + } + } + + public bool AppendMessage(string text) => _store.AppendMessage(_annotation, text); + + public bool ToggleResolved() => _store.ToggleResolved(_annotation); + } + } + + /// + /// winforms-free-lexeme-editor.md D2 — the native Avalonia Messages row: claims the legacy + /// SIL.FieldWorks.XWorks.LexEd.MessageSlice layout identity through the D1 plugin + /// contract and renders over LibChorus at the slice's real + /// in-tree position, retiring the WinForms companion strip's only designated class. The fenced + /// edit context is not used: Chorus notes live in .ChorusNotes files, not LCModel — there is + /// nothing to stage or undo (legacy MessageSlice wrote through immediately too). + /// + public sealed class ChorusNotesPlugin : IRegionEditorPlugin + { + public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; + + public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache) + { + if (obj == null || cache == null) + return null; + ChorusNotesStore store = null; + try + { + store = new ChorusNotesStore(cache.ProjectId.ProjectFolder); + var model = new ChorusNotesEntryModel(store, obj, cache); + // The control owns the store: disposing on detach performs the final save (§6). + return new ChorusNotesBarControl(model, store); + } + catch (Exception e) + { + // Graceful degradation, same policy as the companion lane: without a usable notes + // store the row degrades to the explicit unsupported row (the view's guard lane for + // a null factory result) — never take the pane down. + Logger.WriteEvent($"ChorusNotesPlugin: notes bar unavailable for '{obj.Guid}': {e}"); + store?.Dispose(); + return null; + } + } + } +} diff --git a/Src/xWorks/DialogLauncherPlugins.cs b/Src/xWorks/DialogLauncherPlugins.cs new file mode 100644 index 0000000000..e33fe90583 --- /dev/null +++ b/Src/xWorks/DialogLauncherPlugins.cs @@ -0,0 +1,188 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using Avalonia.Controls; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.Reporting; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D4 — the generic dialog-launcher plugin: a claimed slice + /// renders as an Avalonia value row (: read-only value text + /// + "..." button) whose button calls the host-injected + /// seam with the row's (object, node). The pane stays WinForms-free; the WinForms dialog is + /// the sanctioned coexistence carve-out and lives behind the seam. Without injected services + /// the value still renders and the button is disabled with a tooltip. The fenced edit context + /// is unused: the dialog commits through its own UOW and the refresh controller re-renders via + /// PropChanged. + /// + public sealed class LauncherRegionPlugin : IServiceAwareRegionEditorPlugin + { + private readonly Func _valueReader; + + public LauncherRegionPlugin(string legacyClassName, + Func valueReader) + { + LegacyClassName = legacyClassName; + _valueReader = valueReader ?? throw new ArgumentNullException(nameof(valueReader)); + } + + public string LegacyClassName { get; } + + public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, + LcmCache cache) + => BuildControl(obj, node, editContext, cache, null); + + public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, + LcmCache cache, RegionEditorServices services) + { + if (obj == null || node == null) + return null; + + string value; + try + { + value = _valueReader(obj, node, cache) ?? string.Empty; + } + catch (Exception e) + { + // A broken value read degrades to an empty value, never a missing row. + Logger.WriteEvent($"LauncherRegionPlugin ({LegacyClassName}): value read failed: {e}"); + value = string.Empty; + } + + var launcher = services?.LegacyDialogLauncher; + Action launch = launcher == null + ? (Action)null + : () => + { + try + { + // The result is intentionally unobserved: a committed dialog raises + // PropChanged and the host's refresh controller recomposes the region. + launcher.LaunchFor(obj, node); + } + catch (Exception e) + { + Logger.WriteEvent($"LauncherRegionPlugin ({LegacyClassName}): launch failed: {e}"); + } + }; + return new FwDialogLauncherField(value, node.Label ?? node.Field, launch); + } + } + + /// + /// The D4 launcher-routed slice classes and their plugin/value-reader recipes. Value display + /// matches each legacy slice's own view: the MSA launcher view renders the feature structure's + /// ShortName (CmAnalObjectVc kfragShortName over MsaInflectionFeatureListDlgLauncherView), the + /// phonological launcher's deParams say displayProperty="LongName", and AudioVisualVc renders + /// the media file's AbsoluteInternalPath. + /// + public static class DialogLauncherPlugins + { + /// MSA "Inflection Features"/"Required Features" launchers (MorphologyParts.xml). + public const string MsaFeatureSliceClassName = + "SIL.FieldWorks.XWorks.LexEd.MsaInflectionFeatureListDlgLauncherSlice"; + + /// Phonological features launcher (PhPhoneme/PhNCFeatures, MorphologyParts.xml). + public const string PhonologicalFeatureSliceClassName = + "SIL.FieldWorks.XWorks.LexEd.PhonologicalFeatureListDlgLauncherSlice"; + + /// Pronunciation media (CmMedia-Detail-MediaFile, LexEntryParts.xml). + public const string AudioVisualSliceClassName = + "SIL.FieldWorks.Common.Framework.DetailControls.AudioVisualSlice"; + + public static LauncherRegionPlugin CreateMsaInflectionFeatures() + => new LauncherRegionPlugin(MsaFeatureSliceClassName, + (obj, node, cache) => ResolveFeatureStructure(obj, node, cache, out _)?.ShortName); + + public static LauncherRegionPlugin CreatePhonologicalFeatures() + => new LauncherRegionPlugin(PhonologicalFeatureSliceClassName, + (obj, node, cache) => ResolveFeatureStructure(obj, node, cache, out _)?.LongName); + + public static LauncherRegionPlugin CreateAudioVisual() + => new LauncherRegionPlugin(AudioVisualSliceClassName, + (obj, node, cache) => ReadMediaFilePath(obj)); + + /// + /// The (feature structure, flid) resolution every launcher site shares, mirroring + /// MsaInflectionFeatureListDlgLauncherSlice.Install / GetFeatureStructureFromMSA: a layout + /// `field=` resolves to the owning atomic flid on the row's object and the structure is + /// its current value (possibly null — the dialog creates it); without a field the row's + /// object IS the structure (FsFeatStruc-Detail-FeatureSpecs) and the flid is FeatureSpecs. + /// + internal static IFsFeatStruc ResolveFeatureStructure(ICmObject obj, ViewNode node, + LcmCache cache, out int flid) + { + flid = 0; + if (obj == null || cache == null) + return null; + + if (!string.IsNullOrEmpty(node?.Field)) + { + try + { + flid = cache.DomainDataByFlid.MetaDataCache.GetFieldId2(obj.ClassID, node.Field, true); + } + catch (Exception) + { + flid = 0; + } + } + + if (flid != 0) + { + var hvoFs = cache.DomainDataByFlid.get_ObjectProp(obj.Hvo, flid); + return hvoFs == 0 + ? null + : cache.ServiceLocator.ObjectRepository.GetObject(hvoFs) as IFsFeatStruc; + } + + if (obj is IFsFeatStruc fs) + { + flid = FsFeatStrucTags.kflidFeatureSpecs; + return fs; + } + return null; + } + + /// + /// The media file behind an AudioVisual row: legacy initializes its launcher with + /// Media.MediaFileRA, so the row's CmMedia (or a CmFile directly) resolves here. + /// + internal static ICmFile ResolveMediaFile(ICmObject obj) + { + switch (obj) + { + case ICmFile file: + return file; + case ICmMedia media: + return media.MediaFileRA; + default: + return null; + } + } + + // AudioVisualVc displays file.AbsoluteInternalPath; fall back to the project-relative + // InternalPath when the absolute resolution throws (no linked-files root in odd hosts). + internal static string ReadMediaFilePath(ICmObject obj) + { + var file = ResolveMediaFile(obj); + if (file == null) + return string.Empty; + try + { + return file.AbsoluteInternalPath ?? string.Empty; + } + catch (Exception) + { + return file.InternalPath ?? string.Empty; + } + } + } +} diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs index 1cd64eb728..101e2f83be 100644 --- a/Src/xWorks/FullEntryRegionComposer.cs +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -86,7 +86,8 @@ private sealed class CompilerSources = new ConcurrentDictionary<(int, string), ViewDefinitionModel>(); } - public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false) + public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool showHiddenFields = false, + RegionEditorPluginRegistry plugins = null, RegionEditorServices services = null) { if (entry == null) throw new ArgumentNullException(nameof(entry)); if (cache == null) throw new ArgumentNullException(nameof(cache)); @@ -95,12 +96,20 @@ public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool if (root == null) return null; - var state = new ComposeState(cache, showHiddenFields); + // winforms-free-lexeme-editor.md D1: plugin rows close over the region's own edit + // context, which only exists after the walk has gathered every setter — a deferred + // accessor bridges the gap (plugin factories run at render time, never during compose). + // D4: host services (the legacy-dialog launcher seam) ride the same closure; null when + // the host supplies none, and service-aware plugins must tolerate that. + IRegionEditContext composedContext = null; + var state = new ComposeState(cache, showHiddenFields, + plugins ?? RegionEditorPluginRegistry.Default, () => composedContext, services); foreach (var node in root.Roots) state.Walk(node, entry, 0); var context = new ComposedRegionEditContext(cache, entry, state.TextSetters, state.OptionSetters, state.ReferenceAddSetters, state.ReferenceRemoveSetters); + composedContext = context; var model = new LexicalEditRegionModel("LexEntry", "Normal", state.Fields, root.Diagnostics); return new ComposedEntryRegion(model, context, state.CustomEditorFields); } @@ -153,16 +162,28 @@ public readonly List CustomEditorFields = new List(); private readonly bool _showHidden; + // winforms-free-lexeme-editor.md D1: the plugin registry consulted FIRST for every + // custom slice, plus the deferred accessor for the edit context plugin factories + // receive (resolved when the factory runs, after Compose has built the context). + private readonly RegionEditorPluginRegistry _plugins; + private readonly Func _editContextAccessor; + // D4: the host-injected services handed to service-aware plugins (null when none). + private readonly RegionEditorServices _services; // Finding A: per-compose memos — the morph-type option list is identical for every // IMoForm, and an item layout's menu/hotlinks binding is identical per (class, layout). private List _morphTypeOptions; private readonly Dictionary<(int ClassId, string LayoutName), (string MenuId, string HotlinksId)> _itemMenuBindings = new Dictionary<(int, string), (string, string)>(); - public ComposeState(LcmCache cache, bool showHiddenFields) + public ComposeState(LcmCache cache, bool showHiddenFields, + RegionEditorPluginRegistry plugins, Func editContextAccessor, + RegionEditorServices services = null) { _cache = cache; _showHidden = showHiddenFields; + _plugins = plugins; + _editContextAccessor = editContextAccessor; + _services = services; _sda = cache.DomainDataByFlid; _mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache; } @@ -503,6 +524,23 @@ private void WalkGroup(ViewNode node, ICmObject obj, int depth) private void WalkField(ViewNode node, ICmObject obj, int depth) { + // winforms-free-lexeme-editor.md D1: a custom slice resolves plugin registry → + // companion strip → unsupported row, in that order and never the other way. The + // registry is consulted FIRST so a migrated class composes as a real in-tree + // Avalonia editor (a RegionFieldKind.Custom row carrying the plugin's control + // factory); only unclaimed classes fall through to the companion/unsupported lanes. + if (!string.IsNullOrEmpty(node.CustomEditorClass)) + { + var plugin = _plugins?.Resolve(node.CustomEditorClass); + if (plugin != null) + { + AddPluginRow(node, obj, depth, plugin); + foreach (var pluginChild in node.Children) + Walk(pluginChild, obj, depth + 1); + return; + } + } + var fieldCountBeforeDispatch = Fields.Count; var editor = (node.RawEditor ?? "").ToLowerInvariant(); switch (editor) @@ -545,7 +583,8 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) break; } - // Companion lane: a dynamically loaded custom slice (editor="Custom" class=...) + // Companion lane (second in the D1 resolution order, after the plugin registry + // claim above): a dynamically loaded custom slice (editor="Custom" class=...) // keeps its legacy class/assembly identity, keyed by the StableId of the row the // dispatch above produced for it — whether that was the explicit unsupported row or // a best-effort read-only rendering (e.g. the Messages slice's field="Self" resolves @@ -830,6 +869,200 @@ private ICmPossibility ResolvePossibilityInList(ICmPossibilityList list, string return possibility.OwningList == list ? possibility : null; } + // ---- winforms-free-lexeme-editor.md D3: the entry-reference vector lane ---- + + internal const string EntrySequenceSliceClassName = + "SIL.FieldWorks.XWorks.LexEd.EntrySequenceReferenceSlice"; + + private const int MaxEntrySearchResults = 50; + + // The lane's gate: a NON-virtual reference vector whose destination signature is + // LexEntry/LexSense — or CmObject when the layout identity is the legacy + // EntrySequenceReferenceSlice (ComponentLexemes/PrimaryLexemes sign ILexEntryOrLexSense + // as plain CmObject). Virtual back-ref vectors (ComplexFormEntries, Subentries, + // VisibleComplexFormBackRefs, VariantFormEntries) stay read-only this wave: their writes + // land on the OTHER entry's LexEntryRef, not on this flid (the legacy launcher's + // AddNewObjectsToProperty overrides) — recorded as the lane's deferred note. + private bool IsEntryOrSenseReferenceVector(ViewNode node, int flid) + { + if (_mdc.get_IsVirtual(flid)) + return false; + int dstClass; + try + { + dstClass = _mdc.GetDstClsId(flid); + } + catch (Exception) + { + return false; + } + if (dstClass == LexEntryTags.kClassId || dstClass == LexSenseTags.kClassId) + return true; + return dstClass == CmObjectTags.kClassId + && string.Equals(node.CustomEditorClass, EntrySequenceSliceClassName, StringComparison.Ordinal); + } + + // D3: the editable entry/sense-reference vector — current refs as headword items, remove + // in-pane, add via type-ahead headword-prefix search over the entry repository (never the + // whole lexicon as options). Writes ride sda.Replace on the flid inside the fenced + // session, like the possibility lane, plus the legacy ComponentLexemes coupling below. + private void AddEntryReferenceVector(ViewNode node, ICmObject obj, int depth, int flid, int count) + { + var items = new List(); + for (var i = 0; i < count; i++) + { + var itemHvo = _sda.get_VecItem(obj.Hvo, flid, i); + var item = _cache.ServiceLocator.ObjectRepository.GetObject(itemHvo); + items.Add(new RegionChoiceOption(item.Guid.ToString(), ResolveEntryOrSenseName(item))); + } + + var stableId = StableId(node, obj); + var hvo = obj.Hvo; + // "Self" for the circular-reference guard: the entry whose pane this is — the row's + // object when it IS an entry, else its owning entry (e.g. obj is the LexEntryRef). + var owningEntry = obj as ILexEntry ?? obj.OwnerOfClass(); + + Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, + node.WritingSystem, RegionFieldKind.ReferenceVector, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, + isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, + hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items, + searchOptions: query => SearchLexicon(query, hvo, flid, owningEntry))); + + ReferenceAddSetters[stableId] = key => + { + var target = ResolveEntryOrSense(key); + if (target == null) + return false; + // Direct self-reference rejects up front (the legacy chooser filters the entry + // itself out of the candidates); LCModel's own validation backstops the deeper + // circular cases below. + var targetEntry = target as ILexEntry ?? ((ILexSense)target).Entry; + if (owningEntry != null && targetEntry == owningEntry) + return false; + var size = _sda.get_VecSize(hvo, flid); + for (var i = 0; i < size; i++) + { + if (_sda.get_VecItem(hvo, flid, i) == target.Hvo) + return false; // duplicates rejected, like the legacy launcher's AddItem + } + try + { + _sda.Replace(hvo, flid, size, size, new[] { target.Hvo }, 1); + } + catch (ArgumentException) + { + // LCModel's circular-component validation (the case legacy surfaces as + // ReportLexEntryCircularReference); reject without staging. + return false; + } + ApplyComponentLexemesAddCoupling(obj, flid, target); + return true; + }; + ReferenceRemoveSetters[stableId] = key => + { + var target = ResolveEntryOrSense(key); + if (target == null) + return false; + var size = _sda.get_VecSize(hvo, flid); + for (var i = 0; i < size; i++) + { + if (_sda.get_VecItem(hvo, flid, i) != target.Hvo) + continue; + _sda.Replace(hvo, flid, i, i + 1, new int[0], 0); + return true; + } + return false; + }; + } + + // Legacy EntrySequenceReferenceLauncher.AddNewObjectsToProperty's ComponentLexemes + // coupling, which LCModel does NOT apply as a side effect (verified by test): a component + // added when PrimaryLexemes is empty becomes the primary lexeme, and (unless the ref is + // typed as a derivative) the complex form shows under the new component + // (ShowComplexFormsIn) — LT-12285 guards the duplicate. Removal needs no twin here: + // LCModel's RemoveObjectSideEffects already clears PrimaryLexemes/ShowComplexFormsIn when + // a component leaves (verified by test). + private void ApplyComponentLexemesAddCoupling(ICmObject obj, int flid, ICmObject added) + { + if (flid != LexEntryRefTags.kflidComponentLexemes || !(obj is ILexEntryRef ler)) + return; + if (ler.PrimaryLexemesRS.Count == 0) + ler.PrimaryLexemesRS.Add(added); + ILexEntryType derivation; + _cache.ServiceLocator.GetInstance() + .TryGetObject(LexEntryTypeTags.kguidLexTypDerivation, out derivation); + if ((derivation == null || !ler.ComplexEntryTypesRS.Contains(derivation)) + && !ler.ShowComplexFormsInRS.Contains(added)) + { + ler.ShowComplexFormsInRS.Add(added); // don't add it twice — LT-12285 + } + } + + // Resolves a search/option key to an entry or sense — exactly the targets + // EntrySequenceReferenceSlice references (ILexEntryOrLexSense); everything else rejects. + private ICmObject ResolveEntryOrSense(string key) + { + if (!Guid.TryParse(key, out var guid)) + return null; + if (!_cache.ServiceLocator.ObjectRepository.TryGetObject(guid, out var target)) + return null; + return target is ILexEntry || target is ILexSense ? target : null; + } + + // Item display: the headword (HeadWord for entries — homograph number and all — and the + // owner-outline headword+sense-number for senses), the same display the legacy slice's + // deParams displayProperty="HeadWord" yields; ShortName is the fallback. + private string ResolveEntryOrSenseName(ICmObject target) + { + switch (target) + { + case ILexEntry entry: + return entry.HeadWord?.Text ?? entry.ShortName ?? string.Empty; + case ILexSense sense: + return sense.OwnerOutlineNameForWs(_cache.DefaultVernWs)?.Text + ?? sense.ShortName ?? string.Empty; + default: + return target?.ShortName ?? string.Empty; + } + } + + // D3's type-ahead lane: case-insensitive headword-prefix search over the entry + // repository (headword/citation form/lexeme form), excluding the pane's own entry and + // the vector's current members (read live, so a staged add drops out of the next + // search), capped at MaxEntrySearchResults, ordered by headword. + private IReadOnlyList SearchLexicon(string query, int hvo, int flid, + ILexEntry owningEntry) + { + if (string.IsNullOrWhiteSpace(query)) + return Array.Empty(); + query = query.Trim(); + + var present = new HashSet(); + var size = _sda.get_VecSize(hvo, flid); + for (var i = 0; i < size; i++) + present.Add(_sda.get_VecItem(hvo, flid, i)); + + return _cache.ServiceLocator.GetInstance().AllInstances() + .Where(entry => entry != owningEntry && !present.Contains(entry.Hvo) + && MatchesHeadwordPrefix(entry, query)) + .Select(entry => new RegionChoiceOption(entry.Guid.ToString(), ResolveEntryOrSenseName(entry))) + .OrderBy(option => option.Name, StringComparer.OrdinalIgnoreCase) + .Take(MaxEntrySearchResults) + .ToList(); + } + + private static bool MatchesHeadwordPrefix(ILexEntry entry, string query) + { + return StartsWithIgnoreCase(entry.HeadWord?.Text, query) + || StartsWithIgnoreCase(entry.CitationForm?.BestVernacularAlternative?.Text, query) + || StartsWithIgnoreCase(entry.LexemeFormOA?.Form?.BestVernacularAlternative?.Text, query); + } + + private static bool StartsWithIgnoreCase(string text, string query) + => !string.IsNullOrEmpty(text) + && text.StartsWith(query, StringComparison.OrdinalIgnoreCase); + // Viewing parity (11.x): every field type the legacy slices display has a rendering here: // booleans as checkboxes (editable), integers editable, dates/gendates formatted, // structured text as paragraph text, references as value rows; explicit unsupported rows @@ -906,6 +1139,19 @@ private void WalkOtherField(ViewNode node, ICmObject obj, int depth) return; } + // winforms-free-lexeme-editor.md D3: a vector whose targets are + // entries/senses (the EntrySequenceReferenceSlice fields — + // ComponentLexemes, PrimaryLexemes, ... on LexEntryRef) composes as an + // editable ReferenceVector whose ADD is a type-ahead lexicon search + // (lexicons search, possibility lists enumerate). + if (IsEntryOrSenseReferenceVector(node, flid)) + { + if (count == 0 && HideWhenEmpty(node)) + return; + AddEntryReferenceVector(node, obj, depth, flid, count); + return; + } + if (count == 0) { AddRowUnlessHiddenWhenEmpty(node, obj, depth); @@ -1005,6 +1251,29 @@ private void AddReadOnlyRow(ViewNode node, ICmObject obj, int depth, string disp menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo)); } + // winforms-free-lexeme-editor.md D1: the plugin-claimed row — RegionFieldKind.Custom + // with the normal label/indent/menu metadata, carrying a factory that closes over + // (object, node, deferred edit context, cache). The factory runs in the view at render + // time, so composing stays side-effect free and the edit context exists by then. + private void AddPluginRow(ViewNode node, ICmObject obj, int depth, IRegionEditorPlugin plugin) + { + var editContextAccessor = _editContextAccessor; + var cache = _cache; + var services = _services; + // D4: a service-aware plugin (the launcher lane) gets the host services through the + // five-argument overload; classic plugins keep the original contract. + Func factory = plugin is IServiceAwareRegionEditorPlugin serviceAware + ? () => serviceAware.BuildControl(obj, node, editContextAccessor?.Invoke(), cache, services) + : (Func)(() => plugin.BuildControl(obj, node, editContextAccessor?.Invoke(), cache)); + Fields.Add(new LexicalEditRegionField(StableId(node, obj), Localize(node.Label) ?? node.Field, + node.Field, node.WritingSystem, RegionFieldKind.Custom, node.EditorClassification, + node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, + isEditable: true, indent: depth, + menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, + objectHvo: obj.Hvo, + controlFactory: factory)); + } + private void WalkUnsupported(ViewNode node, ICmObject obj, int depth) { Fields.Add(new LexicalEditRegionField(StableId(node, obj), Localize(node.Label) ?? node.Field, diff --git a/Src/xWorks/LegacyDialogLauncher.cs b/Src/xWorks/LegacyDialogLauncher.cs new file mode 100644 index 0000000000..b6deb8c757 --- /dev/null +++ b/Src/xWorks/LegacyDialogLauncher.cs @@ -0,0 +1,266 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Reflection; +using System.Windows.Forms; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Utils; +using SIL.Reporting; +using SIL.Utils; +using XCore; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D4 — the host seam a dialog-launcher row calls: the PANE + /// stays WinForms-free, and the host (RecordEditView, the only place allowed to touch + /// WinForms during coexistence) runs the existing legacy dialog. The dialog edits through its + /// own unit of work; the host's is subscribed to + /// the real PropChanged bus, so a committed dialog edit re-renders the region without the + /// launcher doing anything further (verified: the controller's IsRelevant walk covers any + /// object owned by the displayed entry — MSAs and their feature structures are). + /// + public interface ILegacyDialogLauncher + { + /// + /// Runs the legacy dialog (or player) for the slice identified by + /// .CustomEditorClass over . Returns whether + /// anything changed (the dialog committed); refresh rides PropChanged either way. + /// + bool LaunchFor(ICmObject obj, ViewNode node); + } + + /// + /// winforms-free-lexeme-editor.md D4 — the host-injected services a region editor plugin may + /// use beyond (object, node, edit context, cache). Carried as one parameter object so future + /// host services extend it without touching the plugin contract again; threaded from + /// RecordEditView through into the plugin + /// control factories. Null-safe by design: composing without services (tests, preview hosts) + /// hands plugins null and launcher rows render their button disabled. + /// + public sealed class RegionEditorServices + { + /// The legacy-dialog seam (D4); null when the host offers none. + public ILegacyDialogLauncher LegacyDialogLauncher { get; set; } + } + + /// + /// RecordEditView's : the sanctioned WinForms carve-out. + /// Dispatches on the node's legacy class identity: + /// + /// MSA inflection features → SIL.FieldWorks.LexText.Controls.MsaInflectionFeatureListDlg + /// (reflection through , exactly the layouts' own load lane — xWorks + /// cannot reference LexTextControls), the same SetDlgInfo recipe + /// MsaInflectionFeatureListDlgLauncher.HandleChooser uses; the dialog commits through its own + /// UOW in OnClosing, so returning true is "the dialog said OK". + /// Phonological features → PhonologicalFeatureChooserDlg, same recipe + /// (PhonologicalFeatureListDlgLauncher.HandleChooser). + /// AudioVisual media → plays the file the way AudioVisualLauncher.HandleChooser does + /// (SoundPlayer for wav, the OS default app otherwise); never a data change. + /// + /// Any open fenced edit session is settled first (beforeLaunch): a legacy dialog + /// opening its own UOW while the fence holds the write lock would throw, the same hazard the + /// undo guard exists for. + /// + public sealed class WinFormsLegacyDialogLauncher : ILegacyDialogLauncher + { + private const string LexTextControlsDll = "LexTextControls.dll"; + private const string MsaDialogClass = "SIL.FieldWorks.LexText.Controls.MsaInflectionFeatureListDlg"; + private const string PhonDialogClass = "SIL.FieldWorks.LexText.Controls.PhonologicalFeatureChooserDlg"; + // MsaInflectionFeatureListDlgLauncher.HandleChooser's string-table path, verbatim. + private const string FeatureChooserXPath = + "/group[@id='Linguistics']/group[@id='Morphology']/group[@id='FeatureChooser']/"; + + private readonly LcmCache _cache; + private readonly Mediator _mediator; + private readonly PropertyTable _propertyTable; + private readonly Func
_ownerForm; + private readonly Action _beforeLaunch; + + public WinFormsLegacyDialogLauncher(LcmCache cache, Mediator mediator, + PropertyTable propertyTable, Func ownerForm, Action beforeLaunch = null) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _mediator = mediator; + _propertyTable = propertyTable; + _ownerForm = ownerForm; + _beforeLaunch = beforeLaunch; + } + + public bool LaunchFor(ICmObject obj, ViewNode node) + { + if (obj == null || node == null) + return false; + try + { + _beforeLaunch?.Invoke(); + switch (node.CustomEditorClass) + { + case DialogLauncherPlugins.MsaFeatureSliceClassName: + return LaunchFeatureDialog(obj, node, MsaDialogClass, isMsaDialog: true); + case DialogLauncherPlugins.PhonologicalFeatureSliceClassName: + return LaunchFeatureDialog(obj, node, PhonDialogClass, isMsaDialog: false); + case DialogLauncherPlugins.AudioVisualSliceClassName: + return PlayMedia(obj); + default: + Logger.WriteEvent( + $"WinFormsLegacyDialogLauncher: no dialog mapped for '{node.CustomEditorClass}'."); + return false; + } + } + catch (Exception e) + { + // Never take the pane down for a launcher failure; the row simply does nothing. + Logger.WriteEvent($"WinFormsLegacyDialogLauncher: launch failed for '{node.CustomEditorClass}': {e}"); + return false; + } + } + + // The MSA/Phon feature-structure dialogs share one recipe: resolve (fs, flid) the way the + // legacy slice's Install does, hand them to SetDlgInfo (the fs overload when one exists, + // the owner+flid overload when the dialog must create it), ShowDialog over the host form. + // OK commits inside the dialog's own UOW (OnClosing); Yes is the "go configure features" + // jump link. + private bool LaunchFeatureDialog(ICmObject obj, ViewNode node, string dialogClassName, bool isMsaDialog) + { + var fs = DialogLauncherPlugins.ResolveFeatureStructure(obj, node, _cache, out var flid); + if (flid == 0) + return false; + + var dialog = DynamicLoader.CreateObject(LexTextControlsDll, dialogClassName) as Form; + if (dialog == null) + { + Logger.WriteEvent($"WinFormsLegacyDialogLauncher: could not create '{dialogClassName}'."); + return false; + } + + using (dialog) + { + var type = dialog.GetType(); + if (fs != null) + { + // Existing feature structure: both dialogs take (cache, mediator, + // propertyTable, IFsFeatStruc[, owningFlid]). + var args = isMsaDialog + ? new object[] { _cache, _mediator, _propertyTable, fs, flid } + : new object[] { _cache, _mediator, _propertyTable, fs }; + var argTypes = isMsaDialog + ? new[] { typeof(LcmCache), typeof(Mediator), typeof(PropertyTable), typeof(IFsFeatStruc), typeof(int) } + : new[] { typeof(LcmCache), typeof(Mediator), typeof(PropertyTable), typeof(IFsFeatStruc) }; + Invoke(type, dialog, "SetDlgInfo", argTypes, args); + } + else + { + // No feature structure yet: the dialog creates it under (owner, flid). + Invoke(type, dialog, "SetDlgInfo", + new[] { typeof(LcmCache), typeof(Mediator), typeof(PropertyTable), typeof(ICmObject), typeof(int) }, + new object[] { _cache, _mediator, _propertyTable, obj, flid }); + } + + if (isMsaDialog) + { + // The launcher's own title/prompt/link strings (HandleChooser, verbatim path). + dialog.Text = StringTable.Table.GetStringWithXPath("InflectionFeatureTitle", FeatureChooserXPath); + SetProperty(type, dialog, "Prompt", + StringTable.Table.GetStringWithXPath("InflectionFeaturePrompt", FeatureChooserXPath)); + SetProperty(type, dialog, "LinkText", + StringTable.Table.GetStringWithXPath("InflectionFeatureLink", FeatureChooserXPath)); + } + + var result = dialog.ShowDialog(_ownerForm?.Invoke()); + switch (result) + { + case DialogResult.OK: + // Committed by the dialog's own UOW; PropChanged re-renders the region. + return true; + case DialogResult.Yes: + // "Configure features" jump. The MSA dialog exposes the POS to jump to + // (the LT-7167 FollowLink fallback — the only lane available without a + // sibling VectorReferenceLauncher slice); the Phon dialog owns its jump. + if (isMsaDialog) + { + if (_mediator != null + && type.GetProperty("HighestPOS")?.GetValue(dialog) is ICmObject pos) + { +#pragma warning disable 618 // legacy lane: PostMessage is how the launcher posts FollowLink + _mediator.PostMessage("FollowLink", new FwLinkArgs("posEdit", pos.Guid)); +#pragma warning restore 618 + } + } + else + { + type.GetMethod("HandleJump")?.Invoke(dialog, null); + } + return false; + default: + return false; + } + } + } + + // AudioVisualLauncher.HandleChooser, minus the WinForms slice: SoundPlayer for a real wav + // (sniffed by RIFF/WAVE header, like legacy), the OS default app for everything else. + private static bool PlayMedia(ICmObject obj) + { + var file = DialogLauncherPlugins.ResolveMediaFile(obj); + if (file == null) + return false; + var path = FileUtils.ActualFilePath(file.AbsoluteInternalPath); + if (!System.IO.File.Exists(path)) + { + Logger.WriteEvent($"WinFormsLegacyDialogLauncher: media file '{path}' not found."); + return false; + } + + if (IsWavFile(path)) + { + using (var player = new System.Media.SoundPlayer(path)) + player.Play(); + } + else + { + using (System.Diagnostics.Process.Start(path)) + { + } + } + return false; // playing media never changes data + } + + // Legacy AudioVisualLauncher.IsWavFile: look inside the file, not at the extension. + private static bool IsWavFile(string path) + { + using (var fs = System.IO.File.OpenRead(path)) + { + var cbFile = (int)fs.Length; + var rgb = new byte[12]; + if (fs.Read(rgb, 0, 12) < 12) + return false; + if (rgb[0] == 'R' && rgb[1] == 'I' && rgb[2] == 'F' && rgb[3] == 'F' + && rgb[8] == 'W' && rgb[9] == 'A' && rgb[10] == 'V' && rgb[11] == 'E') + { + var cbSize = rgb[4] + (rgb[5] << 8) + (rgb[6] << 16) + (rgb[7] << 24); + return cbSize == cbFile - 8; + } + return false; + } + } + + private static void Invoke(Type type, object target, string methodName, Type[] argTypes, object[] args) + { + var method = type.GetMethod(methodName, argTypes); + if (method == null) + throw new MissingMethodException(type.FullName, methodName); + method.Invoke(target, args); + } + + private static void SetProperty(Type type, object target, string propertyName, string value) + { + type.GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance) + ?.SetValue(target, value); + } + } +} diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index d02a9f70af..1fcaeb1886 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -75,6 +75,10 @@ public class RecordEditView : RecordView, IVwNotifyChange, IFocusablePanePortion // open undo task is never orphaned (an orphan makes the shutdown Save throw "Commit at wrong place"). private readonly RegionEditContextHolder m_regionEditContext = new RegionEditContextHolder(); private AvaloniaRegionRefreshController m_avaloniaRefreshController; + // winforms-free-lexeme-editor.md D4: the host services handed to region editor plugins — + // today only the legacy-dialog launcher seam (this view is the sanctioned WinForms + // carve-out; the pane itself stays WinForms-free). + private RegionEditorServices m_regionEditorServices; // Settle-on-deactivate hook (review round 2): the undo guard is per-stack and cannot reach // other windows' undo stacks, so settle when this view's top-level window loses activation. private EventHandler m_settleOnDeactivate; @@ -667,7 +671,8 @@ private void ShowAvaloniaEntry(ICmObject obj) ComposedEntryRegion composed = null; try { - composed = FullEntryRegionComposer.Compose(lexEntry, Cache, showHidden); + composed = FullEntryRegionComposer.Compose(lexEntry, Cache, showHidden, + services: EnsureRegionEditorServices()); if (composed != null) { region = composed.Model; @@ -703,6 +708,28 @@ private void ShowAvaloniaEntry(ICmObject obj) OnRegionMenuRequested); } + /// + /// winforms-free-lexeme-editor.md D4: the services region editor plugins may use beyond + /// (object, node, edit context, cache) — the legacy-dialog launcher seam, implemented here + /// because this host is the only place allowed to touch WinForms during coexistence. Any + /// open fenced edit session settles before a dialog launches (a legacy dialog opens its own + /// UOW; doing that under the fence's open write lock would throw, the undo-guard hazard). + /// The dialog commits through its own UOW, so the refresh controller's PropChanged + /// subscription re-renders the region after the dialog closes — no explicit refresh here. + /// + private RegionEditorServices EnsureRegionEditorServices() + { + if (m_regionEditorServices == null) + { + m_regionEditorServices = new RegionEditorServices + { + LegacyDialogLauncher = new WinFormsLegacyDialogLauncher(Cache, m_mediator, + m_propertyTable, FindForm, () => m_regionEditContext.Settle()) + }; + } + return m_regionEditorServices; + } + /// /// Hybrid companion lane: tears down the previous companions, instantiates the real legacy /// slice for each designated WinForms-only custom editor the composer found (today the diff --git a/Src/xWorks/RegionEditorPlugins.cs b/Src/xWorks/RegionEditorPlugins.cs new file mode 100644 index 0000000000..54e88228c2 --- /dev/null +++ b/Src/xWorks/RegionEditorPlugins.cs @@ -0,0 +1,193 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using Avalonia.Controls; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D1 — the one plugin contract for every remaining custom + /// editor: builds an Avalonia control for (object, node, edit context) so a legacy dynamically + /// loaded slice (editor="Custom" class=...) can render in-tree at the slice's real + /// position instead of an unsupported row or the WinForms companion strip. Plugins are keyed by + /// the legacy layout identity (, the layout's `class=` + /// attribute already carried on the typed node) — zero layout edits per migration, and the + /// identical mechanism serves the next DataTree tools (Notebook, Morphology) for free. + /// + public interface IRegionEditorPlugin + { + /// + /// The fully qualified legacy slice class this plugin claims (the layout `class=` + /// attribute, e.g. SIL.FieldWorks.XWorks.LexEd.MessageSlice). + /// + string LegacyClassName { get; } + + /// + /// Builds the Avalonia control that replaces the legacy slice for one composed row. Invoked + /// lazily by the view (never during compose); the composer hands the region's own edit + /// context so plugin edits ride the same fenced session as every other row. + /// + Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache); + } + + /// + /// winforms-free-lexeme-editor.md D4 — the back-compatible extension of + /// for plugins that need host-injected services (today the + /// seam): the composer calls the five-argument overload + /// when the plugin implements this interface, passing whatever + /// the host supplied to Compose (null when none — services are always optional). Existing + /// plugins keep the original four-argument contract untouched. + /// + public interface IServiceAwareRegionEditorPlugin : IRegionEditorPlugin + { + /// As , plus the host services (may be null). + Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache, + RegionEditorServices services); + } + + /// + /// winforms-free-lexeme-editor.md D1 — maps legacy slice class names to their + /// . The composer consults per node + /// while walking, FIRST in the resolution order (plugin → companion strip → unsupported row). + /// Thread-safe by immutable snapshot: registration copies under a lock, resolution reads the + /// current snapshot without one, so a compose mid-registration sees a coherent table. + /// + public sealed class RegionEditorPluginRegistry + { + private readonly object _sync = new object(); + private volatile IReadOnlyDictionary _snapshot + = new Dictionary(StringComparer.Ordinal); + + /// The process-wide registry the composer uses unless a caller supplies its own. + public static RegionEditorPluginRegistry Default { get; } = CreateDefault(); + + /// + /// Registers a plugin for its . A legacy + /// class has exactly one owner: re-registering an already-claimed class throws, so two + /// migrations cannot silently fight over a slice. + /// + public void Register(IRegionEditorPlugin plugin) + { + if (plugin == null) + throw new ArgumentNullException(nameof(plugin)); + if (string.IsNullOrEmpty(plugin.LegacyClassName)) + throw new ArgumentException("A region editor plugin must claim a legacy class name (D1).", + nameof(plugin)); + lock (_sync) + { + if (_snapshot.ContainsKey(plugin.LegacyClassName)) + throw new ArgumentException( + $"'{plugin.LegacyClassName}' is already claimed by another plugin.", nameof(plugin)); + var next = new Dictionary((IDictionary)_snapshot, + StringComparer.Ordinal) + { + [plugin.LegacyClassName] = plugin + }; + _snapshot = next; + } + } + + /// The plugin claiming the legacy class, or null when the class is unclaimed. + public IRegionEditorPlugin Resolve(string legacyClassName) + { + if (string.IsNullOrEmpty(legacyClassName)) + return null; + return _snapshot.TryGetValue(legacyClassName, out var plugin) ? plugin : null; + } + + /// The currently claimed legacy class names (a snapshot; burn-down governance, D5). + public IReadOnlyCollection RegisteredClassNames + { + get + { + var snapshot = _snapshot; + return new List(snapshot.Keys); + } + } + + private static RegionEditorPluginRegistry CreateDefault() + { + var registry = new RegionEditorPluginRegistry(); + RegisterBuiltins(registry); + return registry; + } + + // The builtin plugin list. Wave 2 (D2) landed ChorusNotesPlugin — the native Avalonia notes + // bar over LibChorus, retiring the companion strip's only designated class. Wave 3 (D3) is + // a composer lane, not a plugin. Wave 4 (D4) landed the dialog-launcher plugins: value row + // + "..." button calling the host's ILegacyDialogLauncher seam. The + // LexemeEditorBurnDownTests census measures coverage as they land. + internal static void RegisterBuiltins(RegionEditorPluginRegistry registry) + { + registry.Register(new ChorusNotesPlugin()); + registry.Register(DialogLauncherPlugins.CreateMsaInflectionFeatures()); + registry.Register(DialogLauncherPlugins.CreatePhonologicalFeatures()); + registry.Register(DialogLauncherPlugins.CreateAudioVisual()); + } + } + + /// + /// winforms-free-lexeme-editor.md D5 — the lexeme editor's burn-down lanes that are not + /// expressed in code elsewhere. Together with the plugin registry () + /// and the companion designated set (), + /// these classify every custom slice class in the lexeme-editor census; the + /// LexemeEditorBurnDownTests census fails on any unclassified class. + /// + public static class LexemeEditorBurnDown + { + /// + /// Classes that render as an Avalonia value row plus a legacy-dialog launcher button + /// through the ILegacyDialogLauncher host seam (D4, wave 4), each WITH its citation. These + /// classes are ALSO claimed in the default plugin registry (by a + /// ); the census counts that pairing as the single + /// "LauncherRouted" lane. The MSA/phonological launchers live in MSA/FsFeatStruc part + /// files, beyond the LexEntry/LexSense census — registered anyway, forward-looking, for + /// the per-sense "Grammatical Info. Details" sections and the Grammar tools. + /// + public static readonly IReadOnlyDictionary LauncherRoutedClassNames = + new Dictionary(StringComparer.Ordinal) + { + { DialogLauncherPlugins.MsaFeatureSliceClassName, "D4 launcher lane" }, + { DialogLauncherPlugins.PhonologicalFeatureSliceClassName, "D4 launcher lane" }, + { DialogLauncherPlugins.AudioVisualSliceClassName, "D4 launcher lane" } + }; + + /// + /// Explicitly deferred classes, each WITH the gate/lane it rides (D5: deferral is only + /// legitimate with a citation — "documented, not forgotten"). + /// + public static readonly IReadOnlyDictionary ExplicitlyDeferredClassNames = + new Dictionary(StringComparer.Ordinal) + { + // Views-based native text editing; rides the rich-TsString gate (D4/D6). + { "SIL.FieldWorks.XWorks.LexEd.ReversalIndexEntrySlice", "gate 6.13" }, + // Lexical relations need the relation-type model walk; recorded as the D3 lane's + // follow-up so they cannot silently fall off the list. + { "SIL.FieldWorks.XWorks.LexEd.LexReferenceMultiSlice", "D3 follow-up" }, + { "SIL.FieldWorks.XWorks.LexEd.GhostLexRefSlice", "D3 follow-up" } + // AudioVisualSlice graduated to LauncherRoutedClassNames in wave 4 (D4). + }; + + /// + /// Classes absorbed by a composer lane (no plugin needed: the composer recognizes the node + /// by metadata and composes a native editable row), each WITH the lane that absorbed it. + /// Wave 3: EntrySequenceReferenceSlice's entry-reference vectors compose as editable + /// ReferenceVector rows with type-ahead lexicon search (D3). Deferred note for that lane: + /// the slice's VIRTUAL back-ref fields (ComplexFormEntries, Subentries, + /// VisibleComplexFormBackRefs, VariantFormEntries) still render read-only — their writes + /// land on the other entry's LexEntryRef (the legacy launcher's AddNewObjectsToProperty + /// overrides) and ride the D3 follow-up with the relation-type walk. + /// + public static readonly IReadOnlyDictionary LaneAbsorbedClassNames = + new Dictionary(StringComparer.Ordinal) + { + { "SIL.FieldWorks.XWorks.LexEd.EntrySequenceReferenceSlice", "D3 ReferenceVector lane" } + }; + } +} diff --git a/Src/xWorks/xWorks.csproj b/Src/xWorks/xWorks.csproj index f6ab67b2ca..21c74458bf 100644 --- a/Src/xWorks/xWorks.csproj +++ b/Src/xWorks/xWorks.csproj @@ -4,7 +4,10 @@ xWorks SIL.FieldWorks.XWorks net48 - Library 168,169,219,414,649,1635,1702,1701,NU1903 + Library + 168,169,219,414,649,1635,1702,1701,NU1903,AVA2001 false false @@ -21,6 +24,13 @@ portable + + + + @@ -30,6 +40,9 @@ + + diff --git a/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs b/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs new file mode 100644 index 0000000000..0ff22ca7d8 --- /dev/null +++ b/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs @@ -0,0 +1,323 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Chorus.notes; +using NUnit.Framework; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; +using SIL.Progress; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// chorus-notes-contract.md §8 — the five mandatory compatibility assertions for the Avalonia + /// Chorus notes bar's UI-free core (): the plugin reads and + /// writes the SAME files with the SAME ref-URL/key shapes legacy `MessageSlice` wrote, so + /// FLExBridge/S&R and the legacy bar see one notes store. Everything here runs against a + /// temp project folder and real LibChorus repositories — no LCModel needed (the ShortName / + /// AllOwnedObjects key derivation rides the memory cache in + /// below). + /// + [TestFixture] + public class ChorusNotesContractTests + { + private string m_folder; + private ChorusNotesStore m_store; + + /// The entry guid the §4 canonical example annotates (lowercase, like FLEx supplies). + private const string EntryGuid = "6b466f54-f88a-42f6-b770-aca8fee5734c"; + + // Verbatim from chorus-notes-contract.md §4 (canonical example annotation XML, itself + // verbatim from LexEdDllTests/FlexBridgeListenerTests.cs:22-31). + private const string LegacyAnnotationXml = @" + Is this the strongest expression of annoyance? +"; + + // A FLExBridge-style merge-conflict annotation as found in Linguistics/Lexicon/*.lexdb.ChorusNotes: + // keyed by the `guid=` query parameter (no `id=`), class mergeConflict (not resolvable, §5.7). + private const string LexdbConflictAnnotationXml = @" + Both users edited the same field. +"; + + [SetUp] + public void Setup() + { + m_folder = Path.Combine(Path.GetTempPath(), "ChorusNotesContractTests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_folder); + } + + [TearDown] + public void Teardown() + { + m_store?.Dispose(); + m_store = null; + try + { + Directory.Delete(m_folder, true); + } + catch (IOException) + { + // A FileSystemWatcher handle can outlive Dispose by a beat on Windows; the temp + // folder is uniquely named, so leaking it cannot affect another run. + } + } + + private string PrimaryNotesPath => Path.Combine(m_folder, ChorusNotesStore.PrimaryNotesFileName); + + private void WritePrimaryNotesFile(string annotationsXml) + { + File.WriteAllText(PrimaryNotesPath, "" + annotationsXml + ""); + } + + private string CreateLexdbNotesFile(string annotationsXml, string baseName = "Lexicon_04") + { + var lexiconFolder = Path.Combine(m_folder, "Linguistics", "Lexicon"); + Directory.CreateDirectory(lexiconFolder); + var lexdbPath = Path.Combine(lexiconFolder, baseName + ".lexdb"); + File.WriteAllText(lexdbPath, ""); // the data file the notes are "about" + var notesPath = lexdbPath + "." + ChorusNotesStore.ChorusNotesExtension; + File.WriteAllText(notesPath, "" + annotationsXml + ""); + return notesPath; + } + + /// §8.1 — the legacy-format annotation file round-trips through the plugin's core. + [Test] + public void RoundTrip_LegacyAnnotation_IsMatchedForTheEntryGuid_CaseSensitively() + { + WritePrimaryNotesFile(LegacyAnnotationXml); + m_store = new ChorusNotesStore(m_folder); + + var matches = m_store.GetAnnotationsFor(EntryGuid); + Assert.That(matches.Count, Is.EqualTo(1), "the §4 example must be matched off `id=` of the still-escaped ref"); + Assert.That(matches[0].ClassName, Is.EqualTo("question")); + Assert.That(matches[0].Guid, Is.EqualTo("10fa26c9-ce35-4341-8a30-c1aa1250d0e0")); + Assert.That(matches[0].Messages.Single().Text, + Is.EqualTo("Is this the strongest expression of annoyance?")); + + Assert.That(m_store.GetAnnotationsFor(EntryGuid.ToUpperInvariant()), Is.Empty, + "key matching is a case-sensitive raw string compare — FLEx supplies lowercase guids (§3.4)"); + Assert.That(m_store.HasPrimaryNotes(EntryGuid), Is.True, + "ifdata visibility looks at the PRIMARY file only (§6)"); + } + + /// §8.2 — a note written by the Avalonia bar reads back through a fresh legacy repository. + [Test] + public void NewNote_ReadBackThroughAFreshRepository_HasTheLegacyShape() + { + m_store = new ChorusNotesStore(m_folder); + Assert.That(m_store.AddNote(EntryGuid, "bother", "Is this annoying?"), Is.Not.Null); + Assert.That(m_store.AddNote(EntryGuid, "bother", " "), Is.Null, + "a blank note is discarded, nothing written (§5.2)"); + m_store.Dispose(); + m_store = null; + + using (var fresh = AnnotationRepository.FromFile("id", PrimaryNotesPath, new NullProgress())) + { + var note = fresh.GetMatchesByPrimaryRefKey(EntryGuid).Single(); + Assert.That(note.ClassName, Is.EqualTo("question"), "class is always 'question' (§5.1)"); + Assert.That(note.RefUnEscaped, Is.EqualTo( + "silfw://localhost/link?app=flex&database=current&server=&tool=default" + + $"&guid={EntryGuid}&tag=&id={EntryGuid}&label=bother"), + "the silfw template with the same guid in guid= and id=, label last (§4)"); + + var message = note.Messages.Single(); + Assert.That(message.Author, Is.EqualTo(Environment.UserName), + "first message author = Environment.UserName (SendReceiveUser, §5.3)"); + Assert.That(message.Status, Is.Empty, "first message status is '' (§5.4)"); + var dateAttribute = message.Element.Attribute("date")?.Value; + Assert.That(DateTime.TryParseExact(dateAttribute, Annotation.TimeFormatWithTimeZone, + CultureInfo.InvariantCulture, DateTimeStyles.None, out _), Is.True, + $"message date '{dateAttribute}' must use yyyy-MM-ddTHH:mm:ssK"); + } + + var doc = XDocument.Load(PrimaryNotesPath); + Assert.That(doc.Root?.Name.LocalName, Is.EqualTo("notes")); + Assert.That((string)doc.Root?.Attribute("version"), Is.EqualTo("0")); + } + + /// §8.3 — stub-file creation: exact legacy content, and never rewritten when present. + [Test] + public void StubFile_IsCreatedWithTheExactLegacyContent_AndIsNeverRewritten() + { + var stubPath = Path.Combine(m_folder, ChorusNotesStore.StubFileName); + + m_store = new ChorusNotesStore(m_folder); + Assert.That(File.Exists(stubPath), Is.True, "opening the store creates the missing stub (§1)"); + Assert.That(File.ReadAllText(stubPath), Is.EqualTo( + "This is a stub file to provide an attachment point for Lexicon.fwstub.ChorusNotes" + + Environment.NewLine), "the single legacy line, exactly (MessageSlice.cs:86-98)"); + Assert.That(File.ReadAllBytes(stubPath).Take(3), Is.EqualTo(new byte[] { 0xEF, 0xBB, 0xBF }), + "UTF-8 with BOM, like the legacy StreamWriter"); + m_store.Dispose(); + m_store = null; + + // Idempotence: an existing stub — whatever its content — is left untouched. + File.WriteAllText(stubPath, "sentinel"); + m_store = new ChorusNotesStore(m_folder); + Assert.That(File.ReadAllText(stubPath), Is.EqualTo("sentinel")); + } + + /// §8.4 — guid-keyed lexdb conflict notes appear; new notes only ever land in the primary file. + [Test] + public void LexdbNotes_KeyedByGuid_Appear_AndNewNotesNeverLandThere() + { + var lexdbNotesPath = CreateLexdbNotesFile(LexdbConflictAnnotationXml); + m_store = new ChorusNotesStore(m_folder); + + Assert.That(m_store.GetAnnotationsFor(EntryGuid).Select(a => a.ClassName), + Is.EquivalentTo(new[] { "mergeConflict" }), + "notes in Linguistics/Lexicon/*.lexdb.ChorusNotes keyed by guid= show on the entry's bar"); + + var lexdbBytesBefore = File.ReadAllBytes(lexdbNotesPath); + Assert.That(m_store.AddNote(EntryGuid, "bother", "a fresh question"), Is.Not.Null); + + Assert.That(m_store.GetAnnotationsFor(EntryGuid).Select(a => a.ClassName), + Is.EquivalentTo(new[] { "question", "mergeConflict" })); + Assert.That(File.ReadAllBytes(lexdbNotesPath), Is.EqualTo(lexdbBytesBefore), + "new notes always go to the primary repository, never a .lexdb file (§3.3)"); + + m_store.Dispose(); + m_store = null; + using (var primary = AnnotationRepository.FromFile("id", PrimaryNotesPath, new NullProgress())) + { + Assert.That(primary.GetMatchesByPrimaryRefKey(EntryGuid).Single().ClassName, + Is.EqualTo("question"), "the new note landed in Lexicon.fwstub.ChorusNotes"); + } + } + + /// §8.5 — resolve toggles append empty status messages; conflicts are not resolvable. + [Test] + public void ResolveToggles_AppendEmptyStatusMessages_AndConflictNotesAreNotResolvable() + { + CreateLexdbNotesFile(LexdbConflictAnnotationXml); + m_store = new ChorusNotesStore(m_folder); + var note = m_store.AddNote(EntryGuid, "bother", "Is this annoying?"); + + Assert.That(note.CanResolve, Is.True); + Assert.That(m_store.ToggleResolved(note), Is.True); + Assert.That(note.IsClosed, Is.True, "the resolve toggle closes an open question"); + Assert.That(note.Messages.Count(), Is.EqualTo(2), + "SetStatus appends an EMPTY message carrying the new status (§5.4)"); + Assert.That(note.Messages.Last().Text, Is.Empty); + Assert.That(note.Messages.Last().Status, Is.EqualTo(Annotation.Closed)); + + Assert.That(m_store.ToggleResolved(note), Is.True); + Assert.That(note.IsClosed, Is.False, "toggling again reopens"); + Assert.That(note.Messages.Count(), Is.EqualTo(3)); + Assert.That(note.Messages.Last().Status, Is.EqualTo(Annotation.Open)); + + // The toggles persisted through the canonical save lane (§4: never write the file directly). + using (var fresh = AnnotationRepository.FromString("id", + File.ReadAllText(PrimaryNotesPath))) + { + Assert.That(fresh.GetMatchesByPrimaryRefKey(EntryGuid).Single().Messages.Count(), + Is.EqualTo(3)); + } + + // Resolvability pins the SHIPPED LibChorus (6.0.0-beta), not Chorus master docs: its + // Annotation.CanResolve excludes only the lowercase classes "conflict" and "note", so + // "mergeConflict"/"notification" ARE resolvable — the same behavior the legacy WinForms + // bar (built on this very assembly) exhibited. Parity = defer to the library; if a + // Chorus upgrade changes the exclusion list, this pin tells us. + var conflict = m_store.GetAnnotationsFor(EntryGuid).Single(a => a.ClassName == "mergeConflict"); + Assert.That(conflict.CanResolve, Is.True, + "shipped LibChorus: mergeConflict is resolvable (legacy bar parity)"); + Assert.That(new Annotation(System.Xml.Linq.XElement.Parse( + "")).CanResolve, + Is.False, "shipped LibChorus: plain 'note' annotations are not resolvable"); + Assert.That(new Annotation(System.Xml.Linq.XElement.Parse( + "")).CanResolve, + Is.False, "shipped LibChorus: legacy 'conflict' annotations are not resolvable"); + } + + /// §6 — external file change (e.g. after S/R) notifies the store's observers. + [Test] + public void ExternalChangeToThePrimaryFile_RaisesNotesChanged() + { + m_store = new ChorusNotesStore(m_folder); + var notified = new System.Threading.ManualResetEventSlim(false); + m_store.NotesChanged += (s, e) => notified.Set(); + + WritePrimaryNotesFile(LegacyAnnotationXml); // an S/R pulling a note from a teammate + + Assert.That(notified.Wait(TimeSpan.FromSeconds(10)), Is.True, + "the repository's FileSystemWatcher must surface NotifyOfStaleList as NotesChanged"); + } + } + + /// + /// chorus-notes-contract.md §3/§4 over the memory cache: the bar's keys are LOWERCASE guids — + /// the entry's own and every AllOwnedObjects guid (notes attached to senses/allomorphs show on + /// the entry's bar) — and the new-note label is the entry's ShortName (headword). + /// + [TestFixture] + public class ChorusNotesEntryKeyTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + private IMoStemAllomorph m_morph; + private ILexSense m_sense; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + m_morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = m_morph; + m_morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(m_entry, null, "house"); + }); + } + + [Test] + public void EntryKeys_AreLowercaseGuids_IncludingAllOwnedObjects_AndLabelIsShortName() + { + Assert.That(ChorusNotesEntryModel.GetIdForObject(m_entry), + Is.EqualTo(m_entry.Guid.ToString().ToLowerInvariant())); + + var additional = ChorusNotesEntryModel.GetAdditionalIdsForObject(m_entry).ToList(); + Assert.That(additional, Has.Member(m_sense.Guid.ToString().ToLowerInvariant()), + "notes attached to senses show on the entry's bar (§3.4)"); + Assert.That(additional, Has.Member(m_morph.Guid.ToString().ToLowerInvariant()), + "notes attached to allomorphs show on the entry's bar (§3.4)"); + Assert.That(additional, Has.All.Matches(id => id == id.ToLowerInvariant()), + "every key is lowercased for the case-sensitive raw compare"); + + Assert.That(ChorusNotesEntryModel.GetLabelForObject(m_entry), Is.EqualTo(m_entry.ShortName), + "label = ICmObject.ShortName, the headword (§4)"); + } + + [Test] + public void NewNoteUrl_FollowsTheSilfwTemplate_WithTheEntryGuidTwice() + { + var guid = m_entry.Guid.ToString().ToLowerInvariant(); + Assert.That(ChorusNotesStore.BuildRefUrl(guid, m_entry.ShortName), Is.EqualTo( + "silfw://localhost/link?app=flex&database=current&server=&tool=default" + + $"&guid={guid}&tag=&id={guid}&label={m_entry.ShortName}")); + } + } +} diff --git a/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs b/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs new file mode 100644 index 0000000000..9b5964aeae --- /dev/null +++ b/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs @@ -0,0 +1,270 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D4 (wave 4) — the dialog-launcher lane: a claimed slice + /// builds an Avalonia value row + "..." button () whose + /// button calls the host-injected seam with the row's + /// (object, node); without injected services the button renders disabled and launching is a + /// no-op. Value text matches the legacy launcher views (feature-structure ShortName/LongName, + /// media file path). + /// + [TestFixture] + public class DialogLauncherPluginTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + }); + } + + private sealed class FakeLegacyDialogLauncher : ILegacyDialogLauncher + { + public int Calls; + public ICmObject LastObject; + public ViewNode LastNode; + public bool Result = true; + + public bool LaunchFor(ICmObject obj, ViewNode node) + { + Calls++; + LastObject = obj; + LastNode = node; + return Result; + } + } + + private static ViewNode LauncherNode(string field, string legacyClassName, string label = "Launcher") + => new ViewNode("Test/Launcher/#0", ViewNodeKind.Field, label, null, field, "custom", + EditorClassification.Dynamic, null, ViewVisibility.Always, ViewExpansion.NotApplicable, + false, null, Array.Empty(), customEditorClass: legacyClassName, + customEditorAssembly: "LexEdDll.dll"); + + private IMoStemMsa MakeStemMsaWithFeatures(out IFsFeatStruc fs) + { + IMoStemMsa msa = null; + IFsFeatStruc featStruc = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + msa = Cache.ServiceLocator.GetInstance().Create(); + m_entry.MorphoSyntaxAnalysesOC.Add(msa); + featStruc = Cache.ServiceLocator.GetInstance().Create(); + msa.MsFeaturesOA = featStruc; + }); + fs = featStruc; + return msa; + } + + private ICmMedia MakePronunciationMedia(string internalPath) + { + ICmMedia media = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var pronunciation = Cache.ServiceLocator.GetInstance().Create(); + m_entry.PronunciationsOS.Add(pronunciation); + media = Cache.ServiceLocator.GetInstance().Create(); + pronunciation.MediaFilesOS.Add(media); + if (internalPath != null) + { + var folder = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.MediaOC.Add(folder); + var file = Cache.ServiceLocator.GetInstance().Create(); + folder.FilesOC.Add(file); + file.InternalPath = internalPath; + media.MediaFileRA = file; + } + }); + return media; + } + + [Test] + public void LauncherPlugin_WithServices_BuildsRowWiredToTheSeam_WithTheRightObjectAndNode() + { + var media = MakePronunciationMedia("AudioVisual\\hello.wav"); + var node = LauncherNode("MediaFile", DialogLauncherPlugins.AudioVisualSliceClassName, "Media File"); + var fake = new FakeLegacyDialogLauncher(); + var services = new RegionEditorServices { LegacyDialogLauncher = fake }; + + var control = DialogLauncherPlugins.CreateAudioVisual() + .BuildControl(media, node, null, Cache, services); + + Assert.That(control, Is.InstanceOf()); + var row = (FwDialogLauncherField)control; + Assert.That(row.CanLaunch, Is.True, "an injected launcher service enables the button"); + Assert.That(row.Value, Does.EndWith("hello.wav"), + "the value text is the media file path (AudioVisualVc parity)"); + + row.Launch(); // the button-click path + Assert.That(fake.Calls, Is.EqualTo(1)); + Assert.That(fake.LastObject, Is.SameAs(media), "the seam receives the row's own object"); + Assert.That(fake.LastNode, Is.SameAs(node), "the seam receives the row's own typed node"); + } + + [Test] + public void LauncherPlugin_WithoutServices_RendersTheRowDisabled_AndLaunchIsANoOp() + { + var media = MakePronunciationMedia(null); + var node = LauncherNode("MediaFile", DialogLauncherPlugins.AudioVisualSliceClassName, "Media File"); + + // Both the four-argument (classic) and five-argument (null services) paths degrade the + // same way: value renders, button disabled. + var plugin = DialogLauncherPlugins.CreateAudioVisual(); + foreach (var control in new[] + { + plugin.BuildControl(media, node, null, Cache), + plugin.BuildControl(media, node, null, Cache, null), + plugin.BuildControl(media, node, null, Cache, new RegionEditorServices()) + }) + { + Assert.That(control, Is.InstanceOf()); + var row = (FwDialogLauncherField)control; + Assert.That(row.CanLaunch, Is.False, "no launcher service: the button is disabled"); + Assert.That(() => row.Launch(), Throws.Nothing); + } + } + + [Test] + public void MsaLauncherPlugin_ValueIsTheFeatureStructureShortName_AndSeamGetsTheMsa() + { + var msa = MakeStemMsaWithFeatures(out var fs); + var node = LauncherNode("MsFeatures", DialogLauncherPlugins.MsaFeatureSliceClassName, + "Inflection Features"); + var fake = new FakeLegacyDialogLauncher(); + var services = new RegionEditorServices { LegacyDialogLauncher = fake }; + + var row = (FwDialogLauncherField)DialogLauncherPlugins.CreateMsaInflectionFeatures() + .BuildControl(msa, node, null, Cache, services); + + // MsaInflectionFeatureListDlgLauncherView renders the structure with + // CmAnalObjectVc kfragShortName — i.e. the feature structure's ShortName. + Assert.That(row.Value, Is.EqualTo(fs.ShortName ?? string.Empty)); + + row.Launch(); + Assert.That(fake.LastObject, Is.SameAs(msa)); + Assert.That(fake.LastNode, Is.SameAs(node)); + } + + [Test] + public void ResolveFeatureStructure_MirrorsTheLegacySliceInstall() + { + var msa = MakeStemMsaWithFeatures(out var fs); + + // field= resolves the owning atomic flid on the row's object (slice GetFlid). + var withField = DialogLauncherPlugins.ResolveFeatureStructure(msa, + LauncherNode("MsFeatures", DialogLauncherPlugins.MsaFeatureSliceClassName), Cache, out var flid); + Assert.That(withField, Is.SameAs(fs)); + Assert.That(flid, Is.EqualTo(MoStemMsaTags.kflidMsFeatures)); + + // No field: the row's object IS the structure (FsFeatStruc-Detail-FeatureSpecs). + var selfBound = DialogLauncherPlugins.ResolveFeatureStructure(fs, + LauncherNode(null, DialogLauncherPlugins.MsaFeatureSliceClassName), Cache, out var selfFlid); + Assert.That(selfBound, Is.SameAs(fs)); + Assert.That(selfFlid, Is.EqualTo(FsFeatStrucTags.kflidFeatureSpecs)); + + // An empty owning field resolves the flid (the dialog creates the structure) but no fs. + IMoStemMsa bareMsa = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + bareMsa = Cache.ServiceLocator.GetInstance().Create(); + m_entry.MorphoSyntaxAnalysesOC.Add(bareMsa); + }); + var missing = DialogLauncherPlugins.ResolveFeatureStructure(bareMsa, + LauncherNode("MsFeatures", DialogLauncherPlugins.MsaFeatureSliceClassName), Cache, out var bareFlid); + Assert.That(missing, Is.Null); + Assert.That(bareFlid, Is.EqualTo(MoStemMsaTags.kflidMsFeatures)); + } + + [Test] + public void AudioVisualValueReader_ReadsTheMediaFilePath_AndToleratesAMissingFile() + { + var media = MakePronunciationMedia("AudioVisual\\hello.wav"); + Assert.That(DialogLauncherPlugins.ReadMediaFilePath(media), Does.EndWith("hello.wav")); + Assert.That(DialogLauncherPlugins.ReadMediaFilePath(media.MediaFileRA), Does.EndWith("hello.wav"), + "a CmFile row reads the same path (legacy initializes its launcher with the file)"); + + var bare = MakePronunciationMedia(null); + Assert.That(DialogLauncherPlugins.ReadMediaFilePath(bare), Is.Empty, + "no media file yet: empty value, never a throw"); + Assert.That(DialogLauncherPlugins.ReadMediaFilePath(m_entry), Is.Empty, + "a non-media object degrades to an empty value"); + } + + private sealed class FakeServiceAwarePlugin : IServiceAwareRegionEditorPlugin + { + public RegionEditorServices LastServices; + public int FiveArgCalls; + public int FourArgCalls; + + public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; + + public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, + IRegionEditContext editContext, LcmCache cache) + { + FourArgCalls++; + return null; + } + + public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, + IRegionEditContext editContext, LcmCache cache, RegionEditorServices services) + { + FiveArgCalls++; + LastServices = services; + return null; + } + } + + [Test] + public void Compose_ThreadsHostServicesIntoServiceAwarePluginFactories() + { + var registry = new RegionEditorPluginRegistry(); + var plugin = new FakeServiceAwarePlugin(); + registry.Register(plugin); + var services = new RegionEditorServices { LegacyDialogLauncher = new FakeLegacyDialogLauncher() }; + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache, plugins: registry, + services: services); + var row = composed.Model.Fields.Single(f => f.Kind == RegionFieldKind.Custom); + row.ControlFactory(); + + Assert.That(plugin.FiveArgCalls, Is.EqualTo(1), + "a service-aware plugin builds through the five-argument overload"); + Assert.That(plugin.FourArgCalls, Is.EqualTo(0)); + Assert.That(plugin.LastServices, Is.SameAs(services), + "the factory closes over the host's own services instance"); + } + + [Test] + public void Compose_WithoutServices_HandsServiceAwarePluginsNull() + { + var registry = new RegionEditorPluginRegistry(); + var plugin = new FakeServiceAwarePlugin(); + registry.Register(plugin); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache, plugins: registry); + composed.Model.Fields.Single(f => f.Kind == RegionFieldKind.Custom).ControlFactory(); + + Assert.That(plugin.FiveArgCalls, Is.EqualTo(1)); + Assert.That(plugin.LastServices, Is.Null, "services are optional by contract (default null)"); + } + } +} diff --git a/Src/xWorks/xWorksTests/EntryReferenceVectorTests.cs b/Src/xWorks/xWorksTests/EntryReferenceVectorTests.cs new file mode 100644 index 0000000000..bc0dd2b94c --- /dev/null +++ b/Src/xWorks/xWorksTests/EntryReferenceVectorTests.cs @@ -0,0 +1,222 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D3 (wave 3) — entry-reference vectors: the legacy + /// EntrySequenceReferenceSlice fields (ComponentLexemes/PrimaryLexemes on LexEntryRef, + /// targeting ILexEntry OR ILexSense) compose as EDITABLE ReferenceVector rows whose items are + /// headwords and whose ADD is a type-ahead lexicon search () + /// — possibility lists enumerate, lexicons search, so the whole lexicon is never materialized + /// as Options. Writes ride sda.Replace inside the fenced session, plus the legacy launcher's + /// ComponentLexemes coupling (first component becomes the primary lexeme; the complex form + /// shows under new components) which LCModel does NOT apply on add (pinned below). + /// + [TestFixture] + public class EntryReferenceVectorTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; // "burro" — the composed pane's own entry (a complex form) + private ILexEntryRef m_ref; // its complex-form LexEntryRef + private ILexEntry m_casa; // component already in the vector + private ILexEntry m_cantar; // search candidate (shares the "ca" prefix with casa) + private ILexEntry m_perro; // add candidate + private ILexSense m_perroSense; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = MakeEntry("burro"); + m_casa = MakeEntry("casa"); + m_cantar = MakeEntry("cantar"); + m_perro = MakeEntry("perro"); + m_perroSense = Cache.ServiceLocator.GetInstance().Create(); + m_perro.SensesOS.Add(m_perroSense); + m_perroSense.Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("dog", Cache.DefaultAnalWs)); + + m_ref = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EntryRefsOS.Add(m_ref); + m_ref.RefType = LexEntryRefTags.krtComplexForm; + m_ref.ComponentLexemesRS.Add(m_casa); + }); + } + + private ILexEntry MakeEntry(string form) + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString(form, Cache.DefaultVernWs)); + return entry; + } + + private ComposedEntryRegion Compose() => FullEntryRegionComposer.Compose(m_entry, Cache); + + private static LexicalEditRegionField ComponentsField(ComposedEntryRegion composed) + => composed.Model.Fields.Single(f => f.Field == "ComponentLexemes" + && f.Kind == RegionFieldKind.ReferenceVector); + + [Test] + public void Compose_ComponentLexemes_IsEditableReferenceVector_WithHeadwordItems_AndSearchNotOptions() + { + var composed = Compose(); + var field = ComponentsField(composed); + + Assert.That(field.IsEditable, Is.True, + "D3: the entry-reference vector is editable, not an unsupported/read-only row"); + Assert.That(field.Label, Is.EqualTo("Components"), + "the RefType=complex-form conditional picked the Components slice"); + Assert.That(field.Items.Select(i => i.Key), + Is.EqualTo(new[] { m_casa.Guid.ToString() }), "current refs ride the row in vector order"); + Assert.That(field.Items.Single().Name, Is.EqualTo(m_casa.HeadWord.Text), + "items display the headword"); + Assert.That(field.Options, Is.Empty, + "lexicons SEARCH: the whole lexicon is never materialized as options"); + Assert.That(field.SearchOptions, Is.Not.Null, "the add slot is the type-ahead search lane"); + } + + [Test] + public void Search_MatchesHeadwordPrefix_CaseInsensitive_ExcludingSelfAndPresentItems() + { + // NB: the memory-only fixture's cache persists across the fixture's tests, so the + // lexicon may hold look-alike entries from other tests' setups — the assertions are + // containment-based against THIS test's objects. + var composed = Compose(); + var field = ComponentsField(composed); + + var results = field.SearchOptions("CA"); + Assert.That(results.Select(r => r.Key), Does.Contain(m_cantar.Guid.ToString()), + "the headword-prefix match is case-insensitive"); + Assert.That(results.Select(r => r.Key), Does.Not.Contain(m_casa.Guid.ToString()), + "casa is excluded because it is already in the vector"); + Assert.That(results.Select(r => r.Name), + Has.All.StartsWith("ca").IgnoreCase, "every result matches the prefix"); + Assert.That(results.Single(r => r.Key == m_cantar.Guid.ToString()).Name, + Is.EqualTo(m_cantar.HeadWord.Text), "results display the headword"); + + Assert.That(field.SearchOptions("bu").Select(r => r.Key), + Does.Not.Contain(m_entry.Guid.ToString()), + "the pane's own entry is excluded — no self-reference offers"); + Assert.That(field.SearchOptions("zz"), Is.Empty, "a non-matching search returns empty"); + Assert.That(field.SearchOptions(" "), Is.Empty, + "a blank query returns empty rather than enumerating the lexicon"); + } + + [Test] + public void Add_EntryBySearchKey_CommitsAsOneUndoStep_AndAppliesLegacyComponentCoupling() + { + // Legacy-coupling baseline (D3 item 3): a plain ComponentLexemesRS.Add in setup did NOT + // populate PrimaryLexemes/ShowComplexFormsIn — LCModel has no ADD side effect, so the + // composer's setter must carry EntrySequenceReferenceLauncher.AddNewObjectsToProperty's + // coupling explicitly. + Assert.That(m_ref.PrimaryLexemesRS, Is.Empty, + "baseline: LCModel does not couple ComponentLexemes adds by itself"); + + var composed = Compose(); + var field = ComponentsField(composed); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_perro.Guid.ToString()), Is.True, + "a key returned by search (the entry guid) stages through the fenced session"); + composed.EditContext.Commit(); + + Assert.That(m_ref.ComponentLexemesRS.Select(c => c.Guid), + Is.EqualTo(new[] { m_casa.Guid, m_perro.Guid }), "the add appends to the vector"); + Assert.That(m_ref.PrimaryLexemesRS.Select(p => p.Guid), Is.EqualTo(new[] { m_perro.Guid }), + "legacy coupling: the component added while PrimaryLexemes was empty becomes primary"); + Assert.That(m_ref.ShowComplexFormsInRS.Select(s => s.Guid), Does.Contain(m_perro.Guid), + "legacy coupling: a non-derivative complex form shows under the new component"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_ref.ComponentLexemesRS.Select(c => c.Guid), Is.EqualTo(new[] { m_casa.Guid }), + "the add plus its coupling is ONE step on the global undo stack"); + Assert.That(m_ref.PrimaryLexemesRS, Is.Empty); + Assert.That(m_ref.ShowComplexFormsInRS, Is.Empty); + } + + [Test] + public void Add_SenseTarget_IsAccepted_AndDisplaysTheOwnerOutline() + { + var composed = Compose(); + var field = ComponentsField(composed); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_perroSense.Guid.ToString()), + Is.True, "ComponentLexemes targets ILexEntryOrLexSense — a sense guid is a valid key"); + composed.EditContext.Commit(); + Assert.That(m_ref.ComponentLexemesRS.Select(c => c.Guid), Does.Contain(m_perroSense.Guid)); + + var recomposed = ComponentsField(Compose()); + Assert.That(recomposed.Items.Select(i => i.Key), Does.Contain(m_perroSense.Guid.ToString())); + Assert.That(recomposed.Items.Single(i => i.Key == m_perroSense.Guid.ToString()).Name, + Is.EqualTo(m_perroSense.OwnerOutlineNameForWs(Cache.DefaultVernWs).Text), + "a sense item displays the owner-outline headword, like the legacy HeadWord display"); + } + + [Test] + public void Remove_Component_ClearsPrimaryLexemes_ViaLCModelSideEffects() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_ref.PrimaryLexemesRS.Add(m_casa); + m_ref.ShowComplexFormsInRS.Add(m_casa); + }); + + var composed = Compose(); + var field = ComponentsField(composed); + + Assert.That(composed.EditContext.TryRemoveReferenceItem(field, m_casa.Guid.ToString()), Is.True); + composed.EditContext.Commit(); + + Assert.That(m_ref.ComponentLexemesRS, Is.Empty); + // Legacy-coupling finding (D3 item 3), pinned empirically: unlike ADD, the + // PrimaryLexemes REMOVE coupling IS an LCModel side effect, so the composer's plain + // sda.Replace removal needs no twin there. ShowComplexFormsIn is NOT cleared by + // LCModel — and the legacy slice's non-virtual remove path (VectorReferenceView → + // plain vector removal) adds no explicit coupling either, so retaining it is + // legacy-faithful (recorded in the D3 lane notes). + Assert.That(m_ref.PrimaryLexemesRS, Is.Empty, + "LCModel's remove side effects clear the departing component from PrimaryLexemes"); + Assert.That(m_ref.ShowComplexFormsInRS.Select(s => s.Guid), Is.EqualTo(new[] { m_casa.Guid }), + "ShowComplexFormsIn is untouched, exactly like the legacy slice's plain removal"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_ref.ComponentLexemesRS.Select(c => c.Guid), Is.EqualTo(new[] { m_casa.Guid }), + "the remove is one undo step"); + Assert.That(m_ref.PrimaryLexemesRS.Select(p => p.Guid), Is.EqualTo(new[] { m_casa.Guid }), + "undo restores the LCModel-coupled PrimaryLexemes removal too"); + } + + [Test] + public void Add_RejectsSelfDuplicateGarbageAndNonEntryTargets_WithoutOpeningASession() + { + var composed = Compose(); + var field = ComponentsField(composed); + + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_entry.Guid.ToString()), Is.False, + "a direct self-reference (the pane's own entry as its own component) must reject"); + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_casa.Guid.ToString()), Is.False, + "duplicates are rejected, like the legacy launcher's AddItem"); + Assert.That(composed.EditContext.TryAddReferenceItem(field, "not-a-guid"), Is.False); + Assert.That(composed.EditContext.TryAddReferenceItem(field, Guid.NewGuid().ToString()), Is.False, + "an unknown guid must not stage"); + Assert.That(composed.EditContext.TryAddReferenceItem(field, m_ref.Guid.ToString()), Is.False, + "an object that is neither entry nor sense must not stage"); + Assert.That(composed.EditContext.TryRemoveReferenceItem(field, m_cantar.Guid.ToString()), + Is.False, "removing an item that is not in the vector must not stage"); + + Assert.That(composed.EditContext.IsOpen, Is.False, "rejected edits must not open the fence"); + Assert.That(m_ref.ComponentLexemesRS.Count, Is.EqualTo(1), "nothing was written"); + } + } +} diff --git a/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs b/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs new file mode 100644 index 0000000000..282808901d --- /dev/null +++ b/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs @@ -0,0 +1,357 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// winforms-free-lexeme-editor.md D5 — the burn-down is enforced by tests, not intentions: + /// every dynamically loaded custom slice the lexeme editor's part files actually use must be + /// classified in exactly one migration lane (plugin-routed / companion-designated / + /// launcher-routed / explicitly deferred WITH the gate it rides), and the companion-strip + /// designated set may only shrink. A new custom slice appearing in the layouts fails the + /// census until a developer consciously classifies it. + /// + [TestFixture] + public class LexemeEditorBurnDownTests + { + private static string RepoRoot() + { + var dir = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (dir != null && !File.Exists(Path.Combine(dir.FullName, "FieldWorks.sln"))) + dir = dir.Parent; + Assert.That(dir, Is.Not.Null, "could not locate the repo root from the test directory"); + return dir.FullName; + } + + /// + /// The lexeme editor's custom-slice census (the table in winforms-free-lexeme-editor.md): + /// every dynamically loaded editor class in LexEntryParts.xml + LexSenseParts.xml. The + /// DynamicLoader signature is the class= + assemblyPath= attribute pair — a plain class= + /// attribute is a model-class bin/part declaration, not an editor. Non-UI handlers + /// (LexEntryChangeHandler and anything else ending ChangeHandler) have no editor to + /// migrate and are excluded. + /// + private static IReadOnlyList LexemeEditorCustomSliceCensus() + { + var partsDir = Path.Combine(RepoRoot(), "DistFiles", "Language Explorer", "Configuration", "Parts"); + var classes = new SortedSet(StringComparer.Ordinal); + foreach (var file in new[] { "LexEntryParts.xml", "LexSenseParts.xml" }) + { + var path = Path.Combine(partsDir, file); + Assert.That(File.Exists(path), Is.True, $"census source '{path}' must exist"); + foreach (var element in XDocument.Load(path).Descendants()) + { + var className = (string)element.Attribute("class"); + if (string.IsNullOrEmpty(className) || element.Attribute("assemblyPath") == null) + continue; + if (className.EndsWith("ChangeHandler", StringComparison.Ordinal)) + continue; + classes.Add(className); + } + } + return classes.ToList(); + } + + [Test] + public void Census_EveryCustomSliceClass_IsClassifiedInExactlyOneLane() + { + var census = LexemeEditorCustomSliceCensus(); + Assert.That(census, Is.Not.Empty, "the lexeme editor part files ship custom slices"); + + foreach (var cls in census) + { + var lanes = new List(); + // D4: a launcher-routed class is DELIVERED through the plugin registry (its + // LauncherRegionPlugin claims it), so the registry claim plus the launcher list + // count as the one "LauncherRouted" lane, not two lanes. + var launcherRouted = LexemeEditorBurnDown.LauncherRoutedClassNames.ContainsKey(cls); + if (RegionEditorPluginRegistry.Default.Resolve(cls) != null && !launcherRouted) + lanes.Add("PluginRouted"); + if (AvaloniaCompanionSlices.DesignatedClassNames.Contains(cls)) + lanes.Add("CompanionDesignated"); + if (launcherRouted) + lanes.Add("LauncherRouted"); + if (LexemeEditorBurnDown.ExplicitlyDeferredClassNames.ContainsKey(cls)) + lanes.Add("ExplicitlyDeferred"); + if (LexemeEditorBurnDown.LaneAbsorbedClassNames.ContainsKey(cls)) + lanes.Add("LaneAbsorbed"); + + Assert.That(lanes.Count, Is.EqualTo(1), + $"'{cls}' must be classified in exactly one burn-down lane, found " + + (lanes.Count == 0 ? "none" : string.Join(" + ", lanes)) + + ". A new custom slice in the lexeme-editor layouts must be consciously classified " + + "before it ships: register an IRegionEditorPlugin for it, designate it for the " + + "WinForms companion strip, list it launcher-routed (wave 4), or defer it explicitly " + + "WITH the gate it rides (winforms-free-lexeme-editor.md D1/D5)."); + } + } + + [Test] + public void Census_FindsTheMeasuredProblemClasses() + { + // The census parser must keep seeing the classes the decision doc measured — if the + // attribute shapes in the part files ever change, the census must change with them + // rather than silently going empty (which would make every class "classified"). + var census = LexemeEditorCustomSliceCensus(); + Assert.That(census, Does.Contain(AvaloniaCompanionSlices.MessageSliceClassName)); + Assert.That(census, Does.Contain("SIL.FieldWorks.XWorks.LexEd.EntrySequenceReferenceSlice")); + Assert.That(census, Has.None.EndsWith("ChangeHandler"), + "non-UI change handlers are not editors and stay out of the census"); + } + + [Test] + public void CompanionDesignatedSet_IsEmpty_AndMayOnlyShrink() + { + // winforms-free-lexeme-editor.md D5: the companion-strip designated set may only SHRINK + // (a class graduates unsupported → companion → plugin, never the other way). Wave 2 + // (ChorusNotesPlugin, D2) emptied it: the Messages slice graduated to the native + // Avalonia plugin, and the mechanism stays as the documented coexistence lane for + // future tools' WinForms-only slices. If this assertion is failing because the set + // GREW, that is a new WinForms dependency inside the pane — do not edit this test + // without a written justification in the change doc. + Assert.That(AvaloniaCompanionSlices.DesignatedClassNames, Is.Empty); + } + + [Test] + public void ExplicitlyDeferredClasses_EachCiteTheirGate() + { + // D5: deferral is only legitimate with the gate it rides spelled out (seeded from the + // decision doc: D3 follow-ups, wave 3/4 lanes, and the 6.13 Views-text long pole). + // Wave 4 (D4): AudioVisualSlice graduated out of this set into the launcher lane. + var expected = new Dictionary(StringComparer.Ordinal) + { + { "SIL.FieldWorks.XWorks.LexEd.ReversalIndexEntrySlice", "gate 6.13" }, + { "SIL.FieldWorks.XWorks.LexEd.LexReferenceMultiSlice", "D3 follow-up" }, + { "SIL.FieldWorks.XWorks.LexEd.GhostLexRefSlice", "D3 follow-up" } + }; + Assert.That(LexemeEditorBurnDown.ExplicitlyDeferredClassNames, Is.EquivalentTo(expected)); + Assert.That(LexemeEditorBurnDown.ExplicitlyDeferredClassNames.Values, + Has.All.Not.Empty, "a deferral without a citation is a forgotten class, not a decision"); + } + + [Test] + public void LaneAbsorbedClasses_AreTheD3ReferenceVectorLane_WithCitations() + { + // Wave 3 (D3): EntrySequenceReferenceSlice graduated out of ExplicitlyDeferred — its + // nodes now compose as editable ReferenceVector rows with type-ahead lexicon search + // (no plugin: the composer recognizes them by metadata + the legacy class identity). + var expected = new Dictionary(StringComparer.Ordinal) + { + { "SIL.FieldWorks.XWorks.LexEd.EntrySequenceReferenceSlice", "D3 ReferenceVector lane" } + }; + Assert.That(LexemeEditorBurnDown.LaneAbsorbedClassNames, Is.EquivalentTo(expected)); + Assert.That(LexemeEditorBurnDown.LaneAbsorbedClassNames.Values, Has.All.Not.Empty, + "a lane absorption without a citation is unverifiable"); + } + + [Test] + public void LauncherRoutedClasses_AreTheD4LauncherLane_WithCitations() + { + // Wave 4 (D4): the dialog-launcher slices render as an Avalonia value row + "..." + // button calling the host's ILegacyDialogLauncher seam. AudioVisualSlice graduated + // here from ExplicitlyDeferred; the MSA/phonological launchers live in MSA/FsFeatStruc + // part files beyond the LexEntry/LexSense census — registered anyway, forward-looking. + var expected = new Dictionary(StringComparer.Ordinal) + { + { DialogLauncherPlugins.MsaFeatureSliceClassName, "D4 launcher lane" }, + { DialogLauncherPlugins.PhonologicalFeatureSliceClassName, "D4 launcher lane" }, + { DialogLauncherPlugins.AudioVisualSliceClassName, "D4 launcher lane" } + }; + Assert.That(LexemeEditorBurnDown.LauncherRoutedClassNames, Is.EquivalentTo(expected)); + Assert.That(LexemeEditorBurnDown.LauncherRoutedClassNames.Values, Has.All.Not.Empty, + "a launcher routing without a citation is unverifiable"); + } + + [Test] + public void LauncherRoutedClasses_AreEachClaimedByALauncherPlugin() + { + // The launcher lane is delivered through the D1 plugin registry: every launcher-routed + // class must be claimed by a LauncherRegionPlugin in the default registry, or the row + // would silently fall back to the unsupported lane while the burn-down claims victory. + foreach (var cls in LexemeEditorBurnDown.LauncherRoutedClassNames.Keys) + { + Assert.That(RegionEditorPluginRegistry.Default.Resolve(cls), + Is.InstanceOf(), + $"'{cls}' is launcher-routed and must be claimed by a LauncherRegionPlugin"); + } + } + + private sealed class StubPlugin : IRegionEditorPlugin + { + public StubPlugin(string legacyClassName) + { + LegacyClassName = legacyClassName; + } + + public string LegacyClassName { get; } + + public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, + IRegionEditContext editContext, LcmCache cache) => null; + } + + [Test] + public void Registry_RegisterAndResolve_RoundTrips_AndUnknownReturnsNull() + { + var registry = new RegionEditorPluginRegistry(); + Assert.That(registry.Resolve("No.Such.Class"), Is.Null, "an unclaimed class resolves null"); + Assert.That(registry.Resolve(null), Is.Null); + + var plugin = new StubPlugin("SIL.FieldWorks.XWorks.LexEd.SomeSlice"); + registry.Register(plugin); + Assert.That(registry.Resolve(plugin.LegacyClassName), Is.SameAs(plugin)); + Assert.That(registry.Resolve("No.Such.Class"), Is.Null); + } + + [Test] + public void Registry_RejectsInvalidAndDuplicateRegistrations() + { + var registry = new RegionEditorPluginRegistry(); + Assert.That(() => registry.Register(null), Throws.ArgumentNullException); + Assert.That(() => registry.Register(new StubPlugin(null)), Throws.ArgumentException, + "a plugin without a legacy class identity cannot be resolved by the composer"); + + registry.Register(new StubPlugin("A.Class")); + Assert.That(() => registry.Register(new StubPlugin("A.Class")), Throws.ArgumentException, + "a legacy class has exactly one owner (single resolution, D1)"); + } + + [Test] + public void DefaultRegistry_BuiltinsAreExactlyTheLandedWaves() + { + // The burn-down measured: wave 2 (D2) promoted the Messages slice from + // CompanionDesignated to PluginRouted (ChorusNotesPlugin, the native notes bar over + // LibChorus); wave 3 (D3) was a composer lane, no plugin; wave 4 (D4) added the three + // dialog-launcher plugins. The census test above keeps every class in exactly one lane. + Assert.That(RegionEditorPluginRegistry.Default.RegisteredClassNames, + Is.EquivalentTo(new[] + { + AvaloniaCompanionSlices.MessageSliceClassName, + DialogLauncherPlugins.MsaFeatureSliceClassName, + DialogLauncherPlugins.PhonologicalFeatureSliceClassName, + DialogLauncherPlugins.AudioVisualSliceClassName + })); + Assert.That(RegionEditorPluginRegistry.Default.Resolve(AvaloniaCompanionSlices.MessageSliceClassName), + Is.InstanceOf()); + } + } + + /// + /// winforms-free-lexeme-editor.md D1 — the composer's resolution order for a custom slice is + /// plugin registry → companion strip (designated set) → unsupported row. A plugin claiming the + /// Messages slice's legacy class therefore wins over the companion lane: the node composes as a + /// RegionFieldKind.Custom row carrying the plugin's deferred control factory, and the + /// companion-promotion list no longer sees it. + /// + [TestFixture] + public class RegionEditorPluginResolutionOrderTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + }); + } + + private sealed class FakeMessagesPlugin : IRegionEditorPlugin + { + public int BuildCalls; + public ICmObject LastObject; + public ViewNode LastNode; + public IRegionEditContext LastEditContext; + public LcmCache LastCache; + + public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; + + public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, + IRegionEditContext editContext, LcmCache cache) + { + BuildCalls++; + LastObject = obj; + LastNode = node; + LastEditContext = editContext; + LastCache = cache; + return null; // never rendered in this fixture; the view's guard lane covers null + } + } + + [Test] + public void Compose_PluginClaim_WinsOverTheCompanionDesignatedSet() + { + var registry = new RegionEditorPluginRegistry(); + var plugin = new FakeMessagesPlugin(); + registry.Register(plugin); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache, plugins: registry); + + var customRows = composed.Model.Fields.Where(f => f.Kind == RegionFieldKind.Custom).ToList(); + Assert.That(customRows.Count, Is.EqualTo(1), + "the claimed Messages node composes as exactly one Custom row"); + var row = customRows[0]; + Assert.That(row.Label, Is.EqualTo("Messages"), "the plugin row keeps the slice label"); + Assert.That(row.MenuId, Is.EqualTo("mnuDataTree-Help"), + "the plugin row carries the layout's slice menu binding"); + Assert.That(row.ObjectHvo, Is.EqualTo(m_entry.Hvo), "field='Self' binds the entry itself"); + Assert.That(row.ControlFactory, Is.Not.Null, "the row carries the plugin's control factory"); + + Assert.That(composed.CustomEditorFields.Select(f => f.ClassName), + Has.No.Member(AvaloniaCompanionSlices.MessageSliceClassName), + "a plugin-claimed class never reaches the companion lane (D1 resolution order)"); + Assert.That(plugin.BuildCalls, Is.EqualTo(0), + "compose defers control building to the view (factory, not control)"); + } + + [Test] + public void PluginRowFactory_ClosesOverObjectNodeCacheAndTheComposedEditContext() + { + var registry = new RegionEditorPluginRegistry(); + var plugin = new FakeMessagesPlugin(); + registry.Register(plugin); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache, plugins: registry); + var row = composed.Model.Fields.Single(f => f.Kind == RegionFieldKind.Custom); + + row.ControlFactory(); + + Assert.That(plugin.BuildCalls, Is.EqualTo(1)); + Assert.That(plugin.LastObject?.Hvo, Is.EqualTo(m_entry.Hvo)); + Assert.That(plugin.LastNode?.CustomEditorClass, + Is.EqualTo(AvaloniaCompanionSlices.MessageSliceClassName)); + Assert.That(plugin.LastCache, Is.SameAs(Cache)); + Assert.That(plugin.LastEditContext, Is.SameAs(composed.EditContext), + "the deferred accessor resolves to the region's own composed edit context"); + } + + [Test] + public void Compose_WithoutAPluginClaim_KeepsTheCompanionLane() + { + // Second slot in the D1 order: an unclaimed designated class still rides the companion + // strip (and an unclaimed, undesignated class keeps its unsupported row). + var composed = FullEntryRegionComposer.Compose(m_entry, Cache, + plugins: new RegionEditorPluginRegistry()); + + Assert.That(composed.Model.Fields.Any(f => f.Kind == RegionFieldKind.Custom), Is.False); + Assert.That(composed.CustomEditorFields.Select(f => f.ClassName), + Has.Member(AvaloniaCompanionSlices.MessageSliceClassName)); + } + } +} diff --git a/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs b/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs index b02d5fefb8..4bead9f443 100644 --- a/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs +++ b/Src/xWorks/xWorksTests/MessagesCompanionLaneTests.cs @@ -2,6 +2,7 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using SIL.FieldWorks.Common.FwAvalonia.Region; @@ -13,13 +14,15 @@ namespace SIL.FieldWorks.XWorks { /// - /// The hybrid companion lane for the LexEntry "Messages" slice (Chorus Send/Receive notes bar): - /// the composer carries the legacy custom-editor identity (class/assembly, keyed by the - /// placeholder row's StableId) instead of dropping it, the designated-class selection picks the - /// Messages slice for promotion, and the model filter removes exactly the promoted rows so the - /// Avalonia region no longer shows the grey unsupported placeholder. The WinForms/Chorus half - /// (PocWinFormsHostControl.SetCompanionControls + the real MessageSlice) is manual-verification - /// territory — headless UI for the Chorus notes bar is impractical. + /// The hybrid companion-strip lane MECHANISM (winforms-free-lexeme-editor.md D1's second + /// resolution slot): the composer carries legacy custom-editor identities (class/assembly, + /// keyed by the placeholder row's StableId) instead of dropping them, designated-class + /// selection picks slices for WinForms promotion, and the model filter removes exactly the + /// promoted rows. Since wave 2 (D2) the designated set is EMPTY — the Messages slice graduated + /// to the native ChorusNotesPlugin — so the mechanism is exercised here with an empty plugin + /// registry (to reach the placeholder lane at all) and a fake designated class; the strip + /// itself stays hidden in the product. The lane remains the documented coexistence path for + /// future tools' WinForms-only custom slices (xml-retirement-blockers.md B11). /// [TestFixture] public class FullEntryRegionMessagesCompanionTests : MemoryOnlyBackendProviderTestBase @@ -38,10 +41,14 @@ public override void TestSetup() }); } + /// An empty registry keeps every custom class in the placeholder/companion lane. + private ComposedEntryRegion ComposeWithoutPlugins() + => FullEntryRegionComposer.Compose(m_entry, Cache, plugins: new RegionEditorPluginRegistry()); + [Test] - public void Compose_CarriesTheMessagesSliceCustomEditorIdentity_KeyedToItsPlaceholderRow() + public void Compose_WithoutAPluginClaim_CarriesTheMessagesSliceIdentity_KeyedToItsPlaceholderRow() { - var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var composed = ComposeWithoutPlugins(); Assert.That(composed, Is.Not.Null, "the shipped layouts must compose"); var messages = composed.CustomEditorFields @@ -66,47 +73,67 @@ public void Compose_CarriesTheMessagesSliceCustomEditorIdentity_KeyedToItsPlaceh "editor='Custom' classifies as a dynamically loaded editor"); } + [Test] + public void Compose_WithTheDefaultRegistry_TheMessagesSliceIsPluginRouted_NotCompanionMaterial() + { + // Wave 2 promotion (D2): the builtin ChorusNotesPlugin claims the Messages class, so the + // composer emits a Custom row with a control factory and the companion lane never sees + // it — with the designated set also empty, the companion strip stays hidden. + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + + Assert.That(composed.CustomEditorFields.Select(f => f.ClassName), + Has.No.Member(AvaloniaCompanionSlices.MessageSliceClassName), + "a plugin-claimed class never reaches the companion lane (D1 resolution order)"); + var custom = composed.Model.Fields.Where(f => f.Kind == RegionFieldKind.Custom + && f.Label == "Messages").ToList(); + Assert.That(custom.Count, Is.EqualTo(1), "the Messages node composes as the plugin's Custom row"); + Assert.That(custom[0].ControlFactory, Is.Not.Null); + + Assert.That(AvaloniaCompanionSlices.SelectPromotions(composed.CustomEditorFields), Is.Empty, + "nothing promotes: the designated set is empty since wave 2, so RecordEditView's " + + "companion strip never shows"); + } + [Test] public void SelectPromotions_PicksOnlyDesignatedCompanionClasses() { - var messages = new ComposedCustomEditorField("id1", - AvaloniaCompanionSlices.MessageSliceClassName, "LexEdDll.dll", "Messages", 17); + // The selection mechanism, exercised with a fake designated class (the product set is + // empty since wave 2; the mechanism remains for future tools). + const string fakeDesignated = "Fake.Tool.WinFormsOnlySlice"; + var designated = new ComposedCustomEditorField("id1", fakeDesignated, "FakeDll.dll", "Fake", 17); var other = new ComposedCustomEditorField("id2", "SIL.FieldWorks.XWorks.LexEd.GhostLexRefSlice", "LexEdDll.dll", "Components", 17); var promotions = AvaloniaCompanionSlices.SelectPromotions( - new[] { other, messages, null }); + new[] { other, designated, null }, + new HashSet { fakeDesignated }); Assert.That(promotions.Select(p => p.FieldStableId), Is.EqualTo(new[] { "id1" }), "only the designated companion classes promote; other dynamic editors keep their unsupported row"); Assert.That(AvaloniaCompanionSlices.SelectPromotions(null), Is.Empty); - } - - [Test] - public void SelectPromotions_OnTheRealComposedEntry_PromotesExactlyTheMessagesSlice() - { - var composed = FullEntryRegionComposer.Compose(m_entry, Cache); - var promotions = AvaloniaCompanionSlices.SelectPromotions(composed.CustomEditorFields); - - Assert.That(promotions.Select(p => p.ClassName), - Is.EqualTo(new[] { AvaloniaCompanionSlices.MessageSliceClassName })); + Assert.That(AvaloniaCompanionSlices.SelectPromotions(new[] { designated, other }), Is.Empty, + "the product designated set is empty since wave 2 (D2)"); } [Test] public void RemovePromotedFields_RemovesExactlyThePromotedRows() { - var composed = FullEntryRegionComposer.Compose(m_entry, Cache); - var promotions = AvaloniaCompanionSlices.SelectPromotions(composed.CustomEditorFields); - var promotedIds = promotions.Select(p => p.FieldStableId).ToList(); + // Promotion removal mechanism over the real composed entry: pick the Messages + // placeholder's StableId directly (composing without plugins), as a stand-in for a + // future designated class. + var composed = ComposeWithoutPlugins(); + var binding = composed.CustomEditorFields + .Single(f => f.ClassName == AvaloniaCompanionSlices.MessageSliceClassName); + var promotedIds = new[] { binding.FieldStableId }; var filtered = AvaloniaCompanionSlices.RemovePromotedFields(composed.Model, promotedIds); - Assert.That(filtered.Fields.Count, Is.EqualTo(composed.Model.Fields.Count - promotedIds.Count), - "exactly the promoted rows disappear; everything else survives"); - Assert.That(filtered.Fields.Any(f => promotedIds.Contains(f.StableId)), Is.False, - "the grey unsupported row for the promoted slice is gone"); + Assert.That(filtered.Fields.Count, Is.EqualTo(composed.Model.Fields.Count - 1), + "exactly the promoted row disappears; everything else survives"); + Assert.That(filtered.Fields.Any(f => f.StableId == binding.FieldStableId), Is.False, + "the placeholder row for the promoted slice is gone"); Assert.That(filtered.Fields.Select(f => f.StableId), - Is.EqualTo(composed.Model.Fields.Where(f => !promotedIds.Contains(f.StableId)) + Is.EqualTo(composed.Model.Fields.Where(f => f.StableId != binding.FieldStableId) .Select(f => f.StableId)), "row order is preserved"); Assert.That(filtered.ClassName, Is.EqualTo(composed.Model.ClassName)); @@ -118,7 +145,7 @@ public void RemovePromotedFields_RemovesExactlyThePromotedRows() [Test] public void RemovePromotedFields_WithNothingToRemove_ReturnsTheSameModelInstance() { - var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var composed = ComposeWithoutPlugins(); Assert.That(AvaloniaCompanionSlices.RemovePromotedFields(composed.Model, null), Is.SameAs(composed.Model)); diff --git a/Src/xWorks/xWorksTests/xWorksTests.csproj b/Src/xWorks/xWorksTests/xWorksTests.csproj index c67c769b37..97a37ff2d3 100644 --- a/Src/xWorks/xWorksTests/xWorksTests.csproj +++ b/Src/xWorks/xWorksTests/xWorksTests.csproj @@ -4,7 +4,8 @@ xWorksTests SIL.FieldWorks.XWorks net48 - Library 168,169,219,414,649,1635,1702,1701,NU1903 + Library + 168,169,219,414,649,1635,1702,1701,NU1903,AVA2001 true false false @@ -22,7 +23,15 @@ portable + + + + + + diff --git a/openspec/changes/lexical-edit-avalonia-migration/chorus-notes-contract.md b/openspec/changes/lexical-edit-avalonia-migration/chorus-notes-contract.md new file mode 100644 index 0000000000..6a623ebd20 --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/chorus-notes-contract.md @@ -0,0 +1,205 @@ +# D2 Contract: Avalonia Chorus Notes Bar (replaces WinForms `NotesBarView`) + +Verified against `Src/LexText/Lexicon/MessageSlice.cs`, `Src/LexText/Lexicon/FLExBridgeListener.cs`, +the `SIL.Chorus.LibChorus` 6.0.0-beta0063 netstandard2.0 assembly (reflection dump; repo pins +6.0.0-beta0065 in `Build/SilVersions.props:18`), and Chorus master sources on GitHub. This is the +compatibility contract `ChorusNotesPlugin` and its tests implement — see +`winforms-free-lexeme-editor.md` decision D2. + +## 1. Files and paths (must match exactly) + +| Item | Value | Source | +|---|---|---| +| Stub file ("file being annotated") | `\Lexicon.fwstub` | `FLExBridgeListener.cs:1062` (`FakeLexiconFileName`) | +| Primary notes file | `\Lexicon.fwstub.ChorusNotes` | `FLExBridgeListener.cs:1066` | +| Notes extension constant | `"ChorusNotes"` (no leading dot) | `FLExBridgeListener.cs:1190`; same as `AnnotationRepository.FileExtension` | +| Additional annotated files | every `\Linguistics\Lexicon\*.lexdb` whose `.ChorusNotes` already exists | `MessageSlice.cs:109-122` | +| LIFT notes file (FLExBridge-converted; not opened by the bar) | `.ChorusNotes` | `FLExBridgeListener.cs:1071-1074` | + +**Stub creation (must replicate):** if `Lexicon.fwstub` is missing, create it UTF-8 with the single +line `"This is a stub file to provide an attachment point for Lexicon.fwstub.ChorusNotes"` +(`MessageSlice.cs:86-98`). The stub is deliberately NOT sent/received; only `.ChorusNotes` is. + +**Notes file creation:** `AnnotationRepository.FromFile` creates a missing file containing exactly +``. Malformed XML throws `AnnotationFormatException` (legacy lets it propagate). + +## 2. LibChorus API surface (namespace `Chorus.notes`, LibChorus 6.0.0-beta0063) + +```csharp +// AnnotationRepository : IAnnotationRepository, IDisposable +static AnnotationRepository FromFile(string primaryRefParameter, string path, IProgress progress); // SIL.Progress.IProgress +static AnnotationRepository FromString(string primaryRefParameter, string contents); +static IEnumerable CreateRepositoriesFromFolder(string folderPath, IProgress progress); +static string FileExtension; // "ChorusNotes" +string AnnotationFilePath { get; } +IAnnotationMutex SavingMutex { get; set; } +void AddAnnotation(Annotation annotation); // appends + notifies observers + sets dirty +void Remove(Annotation annotation); +bool ContainsAnnotation(Annotation annotation); +IEnumerable GetAllAnnotations(); +IEnumerable GetByCurrentStatus(string status); +IEnumerable GetMatches(Func predicate); +IEnumerable GetMatchesByPrimaryRefKey(string key); +void AddObserver(IAnnotationRepositoryObserver observer, IProgress progress); +void RemoveObserver(IAnnotationRepositoryObserver observer); +void Save(IProgress progress); // canonical XML, under SavingMutex +void SaveNowIfNeeded(IProgress progress); // saves only if dirty +void Dispose(); // unhooks watcher, then SaveNowIfNeeded(new NullProgress()) + +// Annotation +Annotation(XElement element); +Annotation(string annotationClass, string refUrl, string path); // new Guid +Annotation(string annotationClass, string refUrl, Guid guid, string path); // refUrl run through UrlHelper.GetEscapedUrl +string ClassName { get; } // "question" | "note" | "mergeConflict" | "notification" +string Guid { get; } +string RefStillEscaped { get; } // raw "ref" attr (indexing uses this) +string RefUnEscaped { get; } +IEnumerable Messages { get; } +Message AddMessage(string author, string status, string contents); // status==null => inherits current Status +string Status { get; } // status attr of LAST message; "" if none +bool IsClosed { get; } // Status.ToLower() == "closed" +void SetStatus(string author, string status); // appends EMPTY message carrying the new status +void SetStatusToClosed(string userName); +bool CanResolve { get; } // false for conflict/notification classes +string GetLabelFromRef(string defaultIfCannotGetIt); // "label" query param +string LabelOfThingAnnotated { get; } +DateTime Date { get; } // date of last message +static readonly string Open = "open"; static readonly string Closed = "closed"; +static string TimeFormatWithTimeZone = "yyyy-MM-ddTHH:mm:ssK"; + +// Message +Message(string author, string status, string contents); // date=now(TimeFormatWithTimeZone), guid=new +string Author { get; } string Status { get; } string Text { get; } +DateTime Date { get; } string Guid { get; } XElement Element { get; } + +// Index / observer / multi-source +class IndexOfAllAnnotationsByKey : AnnotationIndex { IndexOfAllAnnotationsByKey(string nameOfParameterInRefToIndex); } +interface IAnnotationRepositoryObserver { + void Initialize(Func> allAnnotationsFunction, IProgress progress); + void NotifyOfAddition(Annotation a); void NotifyOfModification(Annotation a); + void NotifyOfDeletion(Annotation a); void NotifyOfStaleList(); +} +class MultiSourceAnnotationRepository : IAnnotationRepository { + MultiSourceAnnotationRepository(IAnnotationRepository primary, IEnumerable others); + // GetMatchesByPrimaryRefKey = primary ∪ others; AddAnnotation → primary only; Remove → whichever contains it +} +``` + +GUI-only types to re-implement in Avalonia (they live in WinForms `Chorus.exe`): +`NotesBarView`, `NotesBarModel`, `NoteDetailDialog`. `NotesToRecordMapping` is also Chorus.exe — +copy its three-delegate shape (`FunctionToGetCurrentUrlForNewNotes(object, string escapedId)`, +`FunctionToGoFromObjectToItsId(object)`, `FunctionToGoFromObjectToAdditionalIds(object)`). + +## 3. Repository wiring (what `ChorusSystem.WinForms.CreateNotesBar` did) + +From `MessageSlice.FinishInit` (`MessageSlice.cs:52-77`): + +1. Primary repo: `AnnotationRepository.FromFile("id", projectFolder + @"\Lexicon.fwstub.ChorusNotes", progress)` — + **primary ref parameter is hard-coded `"id"`**. +2. Each additional repo: `AnnotationRepository.FromFile("guid", lexdbPath + ".ChorusNotes", progress)` — + `idAttrForOtherFiles = "guid"`. +3. Wrap in `MultiSourceAnnotationRepository(primary, others)`. New notes always go to the primary. +4. Key matching is **case-sensitive raw string compare** on that query parameter in the + still-escaped `ref` — which is why FLEx supplies **lowercase** guids: + - target id: `((ICmObject)target).Guid.ToString().ToLowerInvariant()` (`MessageSlice.cs:132-135`) + - additional ids: `m_obj.AllOwnedObjects` guids, lowercased (`MessageSlice.cs:137-140`) — notes + attached to senses/allomorphs of the entry show on the entry's bar. +5. Annotations to show = for each target key, `GetMatchesByPrimaryRefKey(key)`, concatenated. + Legacy shows open AND closed notes (no filtering). + +## 4. Ref URL format (compatibility-critical) + +New FLEx lexicon notes carry this `ref` (before XML escaping), built at `MessageSlice.cs:124-130`: + +``` +silfw://localhost/link?app=flex&database=current&server=&tool=default&guid={guid}&tag=&id={guid}&label={entry.ShortName} +``` + +- `{guid}` = entry guid, lowercase, appears twice (`guid=` drives FLEx jump-navigation; `id=` is + what the primary index matches). +- `label` = `ICmObject.ShortName` (headword); last param; may contain spaces/quotes. +- The `Annotation` ctor applies `UrlHelper.GetEscapedUrl` to the whole URL (`&`→`&`, `"`→`"`, + `'`→`'`, `<`→`<`, `>`→`>`), and `RefStillEscaped` (used for indexing) keeps that form. +- LIFT-side refs (`lift://{file}?type=entry&label={label}&id={guid}`) are rewritten to the silfw + form by `FLExBridgeListener.ConvertLiftNotesToFlex` (`FLExBridgeListener.cs:1283-1291`) before the + bar sees them. + +### Canonical example annotation XML (verbatim from `LexEdDllTests/FlexBridgeListenerTests.cs:22-31`) + +```xml + + Is this the strongest expression of annoyance? + +``` + +Root document: ``. Always write through +`AnnotationRepository.Save/SaveNowIfNeeded` (canonical XML under the saving mutex) — never write +the file directly. + +## 5. "Add a new note" behavior + +1. `new Annotation("question", url, "doesntmakesense")` — **class is always `"question"`**; the + path placeholder is irrelevant (repository assigns it on add); url per §4 with the escaped id. +2. Note detail UI: only if the user confirms AND `annotation.Messages.Any()` → + `repository.AddAnnotation(annotation)` then `SaveNowIfNeeded(new NullProgress())` (immediate + flush). Cancel/empty ⇒ discarded, nothing written. +3. First message author = `Environment.UserName` (`FLExBridgeListener.SendReceiveUser`, + `FLExBridgeListener.cs:866-869`; threaded via `MessageSlice.cs:47-55`). +4. First message status = `""` (AddMessage with `null` status inherits empty). Resolve toggles call + `SetStatus(user, "closed"/"open")`, which appends an EMPTY message carrying the status; + `IsClosed` derives from the last message. +5. **FLExBridge displays it with no extra registration** — it enumerates `*.ChorusNotes` in the + repo; the silfw ref is what lets it jump FLEx back to the entry. The note syncs because + `Lexicon.fwstub.ChorusNotes` is in the Hg repo (the `.fwstub` itself is excluded). +6. Existing-note affordances: icon by class + open/closed state; tooltip starts + `ClassName + ": " + LabelOfThingAnnotated` then message texts; click reopens detail, then + `SaveNowIfNeeded`. +7. Resolvability: defer to `Annotation.CanResolve`. **Shipped-library correction (verified + empirically against the pinned 6.0.0-beta assembly, 2026-06-11):** `CanResolve` excludes + only the lowercase classes `"conflict"` and `"note"` — so `mergeConflict`/`notification` + ARE resolvable in this version, matching the legacy WinForms bar built on the same + assembly. (Chorus master docs differ; the contract pins shipped behavior, and the + compatibility test will flag any change on a Chorus upgrade.) + +## 6. Refresh semantics + +- Observe the repository (`IAnnotationRepositoryObserver`); `AnnotationRepository` owns a + `FileSystemWatcher` on its file and raises `NotifyOfStaleList` on external change (e.g. after + S/R) — external refresh is free if the bar observes. Legacy polls a reload flag on a 500 ms + timer; the Avalonia bar can refresh on the notification directly (UI-thread dispatch). +- `ShowSliceForVisibleIfData` ("ifdata" visibility) = any annotation matches the entry guid in the + PRIMARY file only (`MessageSlice.cs:155-172`). +- Dispose order: dispose repositories on teardown; `Dispose()` performs a final `SaveNowIfNeeded`. + +## 7. Writing-system / font behaviors (FWNX-1239; `MessageSlice.cs:69-75`) + +- Label WS = **default vernacular**: language name, WS id, `DefaultFontName`, size 12 — note + label/headword rendering. +- Message WS = **default analysis**: same shape — message text entry/display. +- `Chorus.IWritingSystem` contract: `Name`, `Code` (WS id; used to activate the matching keyboard), + `FontName`, `FontSize`, `ActivateKeyboard()`. The Avalonia bar must (a) render labels in the + vernacular font, (b) render/edit messages in the analysis font, (c) switch the keyboard to the + analysis WS when the message editor gains focus. + +## 8. Compatibility tests must assert + +1. Round-trip: the §4 example XML is readable and matched for entry guid + `6b466f54-f88a-42f6-b770-aca8fee5734c` (case-sensitive lowercase key off `id=` of the + still-escaped ref). +2. A new note written by the Avalonia bar, read back via a fresh + `AnnotationRepository.FromFile("id", path, progress)`: class `question`, ref matches the silfw + template (same guid in `guid=` and `id=`), label = ShortName, one message with author = + `Environment.UserName`, `status=""`, date in `yyyy-MM-ddTHH:mm:ssK`, root ``. +3. Stub-file creation idempotence and exact content (§1). +4. Notes in `Linguistics/Lexicon/*.lexdb.ChorusNotes` keyed by `guid=` appear; new notes never land + there. +5. Resolve toggles append empty status messages; `IsClosed` flips; resolvability matches the + shipped `Annotation.CanResolve` (see §5.7: `note`/`conflict` excluded, `mergeConflict` + resolvable in 6.0.0-beta). diff --git a/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md new file mode 100644 index 0000000000..fc4b9f51fa --- /dev/null +++ b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md @@ -0,0 +1,200 @@ +# WinForms-Free Lexeme Editor — Decisions and Burn-Down + +Status: ACTIVE (decisions approved; waves landing behind tests) +Owner lane: lexical-edit-avalonia-migration, follows B11 (dynamic editors) and the +companion-strip coexistence lane recorded in `xml-retirement-blockers.md`. + +## Goal + +The lexicon Entry pane (LexEntry/Normal detail and everything it composes) renders and edits +with **zero WinForms controls inside the pane**. Two explicit carve-outs, both sanctioned by +existing rules: + +1. **Modal dialogs stay WinForms during coexistence** (the `dialog-ownership.md` rule already + forbids Avalonia modals while both frameworks run). Launching a legacy dialog from an + Avalonia button keeps the *pane* WinForms-free; dialogs migrate in their own workstream. +2. **The host shell** (FwXWindow, panels, the WinForms↔Avalonia interop host) is the shell + phase, not this lane. + +Everything built here must be reusable by the next DataTree tools (Notebook, Morphology, +Grammar) — that constraint drove every decision below. + +## The measured problem + +Custom/dynamic slices actually used by the lexeme editor's part files (LexEntryParts.xml + +LexSenseParts.xml census, 2026-06-11): + +| Legacy slice class | Count | What it is | +|---|---|---| +| `LexEd.EntrySequenceReferenceSlice` | 10 | components / variant-of / complex-form entry-reference vectors | +| `LexEd.GhostLexRefSlice` | 2 | ghost ("type to add") lane for lexical relations | +| `LexEd.LexReferenceMultiSlice` | 2 | lexical relations: one slice generated per relation type | +| `LexEd.MessageSlice` | 1 | Chorus Send/Receive notes bar (WinForms `NotesBarView` from the Chorus repo) | +| `LexEd.ReversalIndexEntrySlice` | 1 | reversal index entries (native-Views editing) | +| `DetailControls.AudioVisualSlice` | 1 | pronunciation media | +| `LexEd.LexEntryChangeHandler` | 1 | not UI — a change handler; no migration needed | +| MSA "Grammatical Info. Details" launchers (`MsaInflectionFeatureListDlgLauncherSlice` etc.) | per-sense section | value + "..." button opening a WinForms feature-structure dialog | + +The morphology rule-formula/interlinear monsters (family 3 in the B11 census) do **not** +appear in the lexeme editor — they ride gate 6.13/8.x and do not block this goal. + +## Decisions + +### D1. One plugin contract for every remaining custom editor (`IRegionEditorPlugin`) + +A registry in xWorks maps the **legacy layout identity** (the `class` attribute the importer +already carries on the typed node, e.g. `SIL.FieldWorks.XWorks.LexEd.MessageSlice`) to a +plugin that builds an **Avalonia control** for (object, node, edit context). The composer +consults the registry while walking; a claimed node composes as a `RegionFieldKind.Custom` +row carrying the plugin's control factory; `LexicalEditRegionView` renders the factory's +control in-tree at the slice's real position. + +Why keyed by legacy class name: the layouts are the contract — keying the registry off the +same attribute means zero layout edits per migration, per-tool reuse for free (Notebook's +layouts route through the identical mechanism), and a measurable burn-down (registry coverage +vs. census). + +Resolution order in the composer: **plugin registry → companion strip (WinForms coexistence) +→ unsupported row**. A class graduates: unsupported → companion → plugin. It never moves the +other way (pinned by the governance test, D5). + +### D2. Messages: native Avalonia notes bar on LibChorus's UI-free core + +`SIL.Chorus.LibChorus` (netstandard2.0) owns the `.ChorusNotes` data model +(`Chorus.notes.AnnotationRepository`, annotations keyed/ref'd by entry guid); only the bar's +pixels live in the WinForms-only `SIL.Chorus.App`. We re-render the bar (`ChorusNotesPlugin` + +an Avalonia notes control) against LibChorus directly and retire the companion strip's only +designated class. + +Compatibility contract (pinned by tests): the plugin reads and writes the **same files** +(`Lexicon.fwstub.ChorusNotes`, the same stub-creation behavior as legacy `MessageSlice`) with +the **same ref-URL/key shapes** legacy wrote, so FLExBridge/S&R and the legacy bar see one +notes store. Tests round-trip: a legacy-format annotation file is read by the plugin; a note +added by the plugin is read back through a fresh LibChorus `AnnotationRepository`. + +Upstreaming an Avalonia bar to the Chorus repo stays open as an option; building against +LibChorus (not FieldWorks internals) keeps that door open without blocking on cross-repo +coordination. + +### D3. Entry-reference vectors ride the existing ReferenceVector lane + +`EntrySequenceReferenceSlice` (components/variants/complex forms) becomes an editable +`ReferenceVector` row: current entries/senses as items (headword text), remove in-pane, with +the 6.3 separator-bar/add-slot affordance. **Add** uses type-ahead search over the lexicon +(headword prefix match via the entry repository) rather than materializing the whole lexicon +as options — possibility lists enumerate; lexicons search. + +`LexReferenceMultiSlice` (relations) and `GhostLexRefSlice` are explicitly **next in this +lane, not this wave**: the multi-slice generates one row per relation type and needs the +relation-type model walk; it reuses the same row/affordance once that walk exists. Recorded +as the lane's follow-up so it cannot silently fall off the list. + +Status (wave 3, 2026-06-11): LANDED. The composer recognizes non-virtual entry/sense-target +reference vectors (signature LexEntry/LexSense, or CmObject under the +`EntrySequenceReferenceSlice` layout identity — ComponentLexemes/PrimaryLexemes) and composes +an editable `ReferenceVector` row whose add slot carries +`LexicalEditRegionField.SearchOptions` (headword/citation/lexeme-form case-insensitive prefix +search over the entry repository, self + already-present excluded, capped at 50); +`FwReferenceVectorField` renders the search flyout; writes ride `sda.Replace` in the fenced +session. The class moved to the burn-down's `LaneAbsorbedClassNames` ("D3 ReferenceVector +lane"). Legacy-coupling findings, pinned by `EntryReferenceVectorTests`: LCModel does NOT +couple ComponentLexemes ADDs, so the setter carries the legacy launcher's coupling +(first-component → PrimaryLexemes when empty; non-derivative → ShowComplexFormsIn, LT-12285 +dedup); LCModel DOES clear PrimaryLexemes on remove (no twin needed), and ShowComplexFormsIn +is retained on remove exactly like the legacy slice's plain removal path. Deferred in-lane: +the slice's VIRTUAL back-ref fields (ComplexFormEntries, Subentries, +VisibleComplexFormBackRefs, VariantFormEntries) still render read-only — their writes land on +the other entry's LexEntryRef (the legacy `AddNewObjectsToProperty` overrides) and ride the +D3 follow-up together with the relations walk. + +### D4. Dialog launchers: Avalonia row + legacy dialog through a host service + +MSA "Grammatical Info. Details" launchers (and any future `*DlgLauncherSlice`) render as an +Avalonia value row plus a launcher button. The button calls an `ILegacyDialogLauncher` +service the host (RecordEditView) injects — the only place allowed to touch WinForms — which +runs the existing dialog and commits through the normal fenced-session path. The pane stays +WinForms-free; the dialog migrates later without touching the pane again. + +`AudioVisualSlice` (pronunciation media) takes the same launcher pattern for "play/choose +file" until the media lane (6.12) lands a native player. `ReversalIndexEntrySlice` is +Views-based text editing and explicitly rides gate 6.13 — documented, not forgotten. + +Status (wave 4, 2026-06-11): LANDED. + +- **Routed:** `MsaInflectionFeatureListDlgLauncherSlice`, `PhonologicalFeatureListDlgLauncherSlice`, + and `AudioVisualSlice` are claimed in the default registry by `LauncherRegionPlugin` + (`DialogLauncherPlugins.cs`) and listed in `LexemeEditorBurnDown.LauncherRoutedClassNames` + ("D4 launcher lane"); AudioVisualSlice graduated out of ExplicitlyDeferred. The MSA/phon + launchers live in MSA/FsFeatStruc part files beyond the LexEntryParts/LexSenseParts census — + registered anyway, forward-looking (per-sense "Grammatical Info. Details", Grammar tools). + Each claimed node renders `FwDialogLauncherField`: the value as read-only text (MSA = the + feature structure's ShortName, exactly the launcher view's CmAnalObjectVc kfragShortName; + phon = LongName per its deParams; AudioVisual = the media file's AbsoluteInternalPath) plus + the legacy "..." button. +- **Seam:** `ILegacyDialogLauncher.LaunchFor(obj, node) → bool changed` plus a + `RegionEditorServices` parameter object, threaded RecordEditView → `Compose(..., services)` → + plugin factories via the back-compatible `IServiceAwareRegionEditorPlugin` extension of the + D1 contract (classic plugins untouched; services default null). Without a host service the + button renders DISABLED with a tooltip and the value still shows. +- **What the host service really does (not a stub):** `WinFormsLegacyDialogLauncher` + (RecordEditView, the sanctioned carve-out) settles any open fenced session first (a legacy + dialog opens its own UOW; doing that under the fence's write lock would throw), then: MSA → + creates `MsaInflectionFeatureListDlg` reflectively through `DynamicLoader` + ("LexTextControls.dll" — xWorks cannot reference it; same load lane as the layouts), resolves + (fs, flid) exactly like the legacy slice's Install, calls the same SetDlgInfo overloads and + title/prompt/link strings HandleChooser uses, and ShowDialogs over the host form; OK means the + dialog committed in its own UOW; Yes posts the FollowLink posEdit jump (the legacy LT-7167 + fallback — the sibling-VectorReferenceLauncher scan needs a DataTree and is intentionally not + replicated). Phonological → `PhonologicalFeatureChooserDlg`, same recipe (HandleJump on Yes). + AudioVisual → plays the file like `AudioVisualLauncher.HandleChooser` (SoundPlayer for + RIFF/WAVE, OS default app otherwise); returns false (no data change). +- **Refresh assumption VERIFIED:** the dialogs commit through their own UOW, which raises + PropChanged; `AvaloniaRegionRefreshController` subscribes to the real bus and its IsRelevant + walk covers any object owned by the displayed entry (MSAs and their feature structures are), + so the region recomposes after the dialog closes (scheduled via the host's UI-thread queue) — + the launcher never refreshes explicitly. +- **Remaining in-lane:** the dialog launch itself is WinForms-modal and verified by code-path + parity + the seam tests (fake launcher), not by automated UI tests; a live-FLEx pass over a + sense's "Grammatical Info. Details" section is the outstanding manual check. The MSA detail + sections are not yet reached by the composer's walk (the sense layout binds `MsaCombo`, not + the MoStemMsa Normal layout), and the pronunciation `MediaFiles` part ref does not resolve + (no `LexPronunciation-Detail-MediaFiles` part ships) — when those walks land, the launcher + rows light up with zero further plugin work. Choose/replace media file (vs play) rides the + media lane (6.12) like the native player. + +### D5. Governance: the burn-down is enforced by tests, not intentions + +- The companion-strip designated set may only **shrink** (pinned: a test asserts its exact + contents; growing it requires consciously editing the test with justification). +- The plugin registry's coverage of the lexeme-editor census above is pinned: a test asserts + which classes are plugin-routed / launcher-routed / explicitly deferred (with the gate + they ride). A new custom slice appearing in the lexeme-editor layouts fails the test until + it is classified. +- `xml-retirement-blockers.md` B11 row references this document as the lexeme-editor lane. + +### D6. Explicitly out of scope here + +Rich TsString editing (gate 6.13), `ReversalIndexEntrySlice` (rides 6.13), the WinForms host +shell, WinForms dialogs themselves, morphology/grammar family-3 editors, native media player +(6.12). Each is listed where it rides; none are needed for the pane itself to be +WinForms-free apart from 6.13's text-fidelity caveat, which is tracked as the program-wide +long pole. + +## Sequence (each wave lands green before the next) + +1. **Wave 1 — plugin contract + governance:** `IRegionEditorPlugin`, registry, composer/view + routing (`RegionFieldKind.Custom` + control factory), resolution-order tests, burn-down + governance tests. +2. **Wave 2 — Messages:** `ChorusNotesPlugin` + Avalonia notes control on LibChorus; file/ref + compatibility tests; companion designated set shrinks to empty. +3. **Wave 3 — entry-reference vectors:** `EntrySequenceReferenceSlice` → ReferenceVector with + type-ahead entry search; composer/edit-context tests over the memory cache. +4. **Wave 4 — dialog launchers:** `ILegacyDialogLauncher` seam + MSA launcher plugin (+ + AudioVisual via the same pattern); seam-level tests with a fake launcher. + +## Reuse statement + +The registry, the resolution order, the launcher seam, the notes control, and the type-ahead +reference editor are all keyed by layout vocabulary and LCModel metadata — none of them know +they are in the lexicon. Notebook (RnGenericRec has its own custom slices) and Morphology +adopt them by registering plugins, not by re-architecting. diff --git a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md index 95245aa047..cd38561ae7 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md +++ b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md @@ -328,10 +328,22 @@ change's surfaces. strip is pinned above the region, not at the slice's legacy mid-tree position between Import Residue and Senses. Covered by `FullEntryRegionMessagesCompanionTests`; the Chorus half is manual-verification (notes bar + add-note icon against an S/R project). +- **Status (2026-06-11) — D4 dialog-launcher lane landed** (`winforms-free-lexeme-editor.md` + D4): `MsaInflectionFeatureListDlgLauncherSlice`, `PhonologicalFeatureListDlgLauncherSlice`, + and `AudioVisualSlice` are plugin-claimed (`LauncherRegionPlugin` in + `DialogLauncherPlugins.cs`) and render as an Avalonia value row + "..." button; the button + calls the host-injected `ILegacyDialogLauncher` seam (`RegionEditorServices`, threaded from + RecordEditView through `FullEntryRegionComposer.Compose`). RecordEditView's + `WinFormsLegacyDialogLauncher` runs the real legacy dialogs reflectively through + `DynamicLoader` (the MSA/phonological feature dialogs; AudioVisual plays the media file) — + the pane stays WinForms-free and the WinForms dialog remains the sanctioned coexistence + carve-out. Burn-down: `LexemeEditorBurnDown.LauncherRoutedClassNames` is populated and + AudioVisualSlice left the deferred set (pinned by `LexemeEditorBurnDownTests`). - **Retirement risk if unaddressed:** High for affected fields (reversal entries, MSA launchers, phonological features render as the resource-backed unsupported state at best). Per-surface 9.4 must enumerate which dynamic editors that surface's layouts reach; the - companion strip is the documented lane for the ones that must stay WinForms (Chorus UI). + companion strip is the documented lane for the ones that must stay WinForms (Chorus UI), + and the D4 launcher lane is the documented lane for dialog-launcher slices. ### B12. Native viewing/render coupling From 1d525a2090338eb3f7f12257ac4b0292166d3aff Mon Sep 17 00:00:00 2001 From: John Lambert Date: Thu, 11 Jun 2026 20:19:04 -0400 Subject: [PATCH 06/14] Jump to lists, gear icons --- Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 3 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 4 + .../BrowseAndCanonicalJsonTests.cs | 16 +- .../DialogLauncherFieldTests.cs | 14 +- .../FwAvaloniaTests/HoverRevealTests.cs | 401 ++++++++++++++++++ .../LayoutImportCoverageTests.cs | 50 +++ .../FwAvaloniaTests/RegionEditingTests.cs | 324 +++++++++++++- .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 5 +- .../FwAvalonia/Region/FwFieldControls.cs | 299 +++++++++++-- Src/Common/FwAvalonia/Region/HoverReveal.cs | 199 +++++++++ .../Region/LexicalEditRegionModel.cs | 59 ++- .../Region/LexicalEditRegionView.cs | 21 +- .../ViewDefinition/LayoutImportCoverage.cs | 12 +- .../ViewDefinitionJsonSerializer.cs | 29 +- .../ViewDefinition/ViewDefinitionModel.cs | 48 ++- .../ViewDefinition/XmlLayoutImporter.cs | 56 ++- Src/xWorks/FullEntryRegionComposer.cs | 41 +- Src/xWorks/RecordEditView.cs | 40 +- .../FullEntryRegionReferenceChooserTests.cs | 45 ++ .../LexicalEditRegionEditingTests.cs | 20 + .../RegionEditLinkDispatchTests.cs | 61 +++ .../layout-import-coverage.md | 12 +- .../winforms-free-lexeme-editor.md | 24 ++ .../xml-retirement-blockers.md | 24 +- 24 files changed, 1725 insertions(+), 82 deletions(-) create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs create mode 100644 Src/Common/FwAvalonia/Region/HoverReveal.cs create mode 100644 Src/xWorks/xWorksTests/RegionEditLinkDispatchTests.cs diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index 247cd5c922..fde5be579e 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -66,5 +66,8 @@ public static class FwAvaloniaStrings /// Tooltip of a disabled launcher button: no host dialog service (D4). public static string LauncherUnavailable => Resources.GetString("ksLauncherUnavailable"); + + /// "{0} settings" — accessible name of a chooser's hover-revealed settings gear. + public static string FieldSettingsFormat => Resources.GetString("ksFieldSettings"); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index 53a34a96d3..a692819399 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -93,4 +93,8 @@ This field is edited in a dialog that is not available in this view. Tooltip of the disabled launcher button when the host provides no dialog service (D4). + + {0} settings + Accessible name of the hover-revealed settings gear on a chooser field; {0} is the field label (e.g. "Morph Type settings"). + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs index fc8450162a..c55938182b 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/BrowseAndCanonicalJsonTests.cs @@ -161,7 +161,12 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() intMemberOf: "2,3,7", lengthAtLeast: 1, lengthAtMost: 4, - guidEquals: "d7f713da-e8cf-11d3-9764-00c04f186933")); + guidEquals: "d7f713da-e8cf-11d3-9764-00c04f186933"), + chooserLinks: new List + { + new ViewChooserLink("goto", "Edit the Publications list", "publicationsEdit"), + new ViewChooserLink("simple", "Add a slot", "MakeInflAffixSlotChooserCommand", "TopPOS") + }); var model = new ViewDefinitionModel("LexEntry", "Normal", "detail", new List { node }, new List()); @@ -212,6 +217,15 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() Assert.That(r.Condition.LengthAtMost, Is.EqualTo(4), "Condition.LengthAtMost"); Assert.That(r.Condition.GuidEquals, Is.EqualTo("d7f713da-e8cf-11d3-9764-00c04f186933"), "Condition.GuidEquals"); + // B7: the chooser jump-link block (label/tool/type/target) survives, including the + // "goto" default-type omission. + Assert.That(r.ChooserLinks, Has.Count.EqualTo(2), nameof(r.ChooserLinks)); + Assert.That(r.ChooserLinks[0].Type, Is.EqualTo("goto"), "ChooserLinks[0].Type"); + Assert.That(r.ChooserLinks[0].Label, Is.EqualTo("Edit the Publications list"), "ChooserLinks[0].Label"); + Assert.That(r.ChooserLinks[0].Tool, Is.EqualTo("publicationsEdit"), "ChooserLinks[0].Tool"); + Assert.That(r.ChooserLinks[0].Target, Is.Null, "ChooserLinks[0].Target"); + Assert.That(r.ChooserLinks[1].Type, Is.EqualTo("simple"), "ChooserLinks[1].Type"); + Assert.That(r.ChooserLinks[1].Target, Is.EqualTo("TopPOS"), "ChooserLinks[1].Target"); }); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DialogLauncherFieldTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DialogLauncherFieldTests.cs index 50ce92798a..741c780622 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DialogLauncherFieldTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DialogLauncherFieldTests.cs @@ -16,9 +16,11 @@ namespace FwAvaloniaTests { /// /// winforms-free-lexeme-editor.md D4 (wave 4) — the dialog-launcher row control: the field's - /// current value as read-only text plus the legacy "..." launcher button. The button invokes - /// the injected callback (the host's ILegacyDialogLauncher seam on the xWorks side); without a - /// callback the button renders disabled with an explanatory tooltip, and the value still shows. + /// current value as read-only text plus the launcher button, drawn as the shared hover-revealed + /// settings gear (it replaced the legacy always-visible "..."). The button invokes the injected + /// callback (the host's ILegacyDialogLauncher seam on the xWorks side); without a callback the + /// button renders disabled with an explanatory tooltip, and the value still shows. Hover-reveal + /// behavior itself is pinned in . /// [TestFixture] public class DialogLauncherFieldTests @@ -35,7 +37,7 @@ private static Button LauncherButton(FwDialogLauncherField row) => row.GetVisualDescendants().OfType - public sealed class FwMultiWsTextField : StackPanel + public sealed class FwMultiWsTextField : StackPanel, IHoverAffordanceProvider { + private readonly List _affordances = new List(); + public FwMultiWsTextField( LexicalEditRegionField field, string automationId, @@ -147,12 +155,111 @@ public FwMultiWsTextField( box.GotFocus += (s, e) => writingSystemFocused(wsTag); } - var rowPanel = new DockPanel(); + var rowPanel = new DockPanel + { + // 14.2: a null background only hit-tests the glyphs — the whole row must + // receive hover so the gear reveal works over the gaps. + Background = Brushes.Transparent + }; DockPanel.SetDock(abbrev, Dock.Left); rowPanel.Children.Add(abbrev); + + // The legacy slice tree-node menu button (every slice has one; the layout's `menu=` + // names what it opens — mnuDataTree-LexemeForm on the Lexeme Form, mnuDataTree-Help + // elsewhere): a hover-revealed gear on the FIRST alternative's row that raises the + // same host menu bridge a label right-click does. + if (Children.Count == 0 && menuRequested != null && !string.IsNullOrEmpty(field.MenuId)) + { + var gearButton = RegionChrome.CreateGearButton(); + AutomationProperties.SetAutomationId(gearButton, automationId + ".Settings"); + AutomationProperties.SetName(gearButton, string.Format(FwAvaloniaStrings.FieldSettingsFormat, + field.Label ?? field.Field ?? automationId)); + gearButton.Click += (s2, e2) => + { + // The menu drops from the gear, like the legacy tree-node button's menu. + var screen = gearButton.PointToScreen(new Point(0, gearButton.Bounds.Height)); + menuRequested(new RegionMenuRequest(field, RegionMenuKind.SliceMenu, screen.X, screen.Y)); + }; + DockPanel.SetDock(gearButton, Dock.Right); + rowPanel.Children.Add(gearButton); + _affordances.Add(gearButton); + } + rowPanel.Children.Add(box); Children.Add(rowPanel); } + + // The gear hides until hover; the whole field panel is a hover source (the region view + // widens the surface to the row's label too, via IHoverAffordanceProvider). + if (_affordances.Count > 0) + HoverReveal.Attach(new Control[] { this }, _affordances); + } + + /// The slice-menu gear (rows with a legacy `menu=` binding); empty otherwise. + public IReadOnlyList HoverAffordances => _affordances; + } + + /// + /// The list-editor jump links shared by the chooser and reference-vector gear flyouts (B7): + /// the legacy chooser dialog's "Edit the … list" LinkLabels (ReallySimpleListChooser.AddLink, + /// LinkType.kGotoLink), drawn below the options as link-styled items after a thin rule. + /// Clicking one closes the flyout and raises the host's + /// callback — the host dispatches the legacy mediator FollowLink jump. + /// + internal static class RegionLinkChrome + { + /// + /// Returns unchanged when the row carries no links (or no + /// callback), else the options stacked over a separator rule and one link item per + /// . + /// + internal static Control WithChooserLinks(Control optionsContent, LexicalEditRegionField field, + string automationId, Action linkRequested, Flyout flyout) + { + if (linkRequested == null || field.ChooserLinks.Count == 0) + return optionsContent; + + var panel = new StackPanel { Spacing = 2 }; + panel.Children.Add(optionsContent); + panel.Children.Add(new Border + { + Height = 1, + Background = Brushes.LightGray, + Margin = new Thickness(0, 4, 0, 2) + }); + + for (var i = 0; i < field.ChooserLinks.Count; i++) + { + var link = field.ChooserLinks[i]; + var item = new Button + { + Content = new TextBlock + { + Text = link.Label, + Foreground = Brushes.RoyalBlue, + TextDecorations = TextDecorations.Underline + }, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + Padding = new Thickness(4, 2, 4, 2), + MinHeight = 0, + HorizontalAlignment = HorizontalAlignment.Left, + Cursor = new Cursor(StandardCursorType.Hand) + }; + AutomationProperties.SetAutomationId(item, automationId + ".Link." + i); + AutomationProperties.SetName(item, link.Label ?? string.Empty); + item.Click += (s, e) => + { + // Legacy order: the chooser dialog closes (Cancel) and THEN the jump posts + // (m_lblLink1_LinkClicked → HandleAnyJump); here the flyout hides, then the + // host callback dispatches FollowLink. + flyout?.Hide(); + linkRequested(new RegionLinkRequest(field, link)); + }; + panel.Children.Add(item); + } + + return panel; } } @@ -162,25 +269,49 @@ public FwMultiWsTextField( /// stages it through the edit context, closes the flyout, and returns focus to the button — the /// popup-focus-return behavior the seam specs require. Without an edit context the chooser is a /// read-only display of the current selection. + /// Chrome (hover-reveal polish): the button is transparent/borderless — the value text reads + /// flat like the legacy combo — and a settings-gear icon fades in on row hover (the modern + /// "this value has a supporting list" affordance). Clicking anywhere on the value still opens + /// the same flyout; staging and automation ids are unchanged. ///
- public sealed class FwChooserField : Button + public sealed class FwChooserField : Button, IHoverAffordanceProvider { private string _selectedKey; + private readonly TextBlock _valueText; + private readonly Control _gear; public FwChooserField( LexicalEditRegionField field, string automationId, - IRegionEditContext editContext) + IRegionEditContext editContext, + Action linkRequested = null) { _selectedKey = field.SelectedOptionKey; Padding = PocDensity.EditorPadding; MinHeight = 0; HorizontalAlignment = HorizontalAlignment.Left; - Content = CurrentName(field); + Background = Brushes.Transparent; + BorderThickness = new Thickness(0); + _valueText = new TextBlock + { + Text = CurrentName(field), + VerticalAlignment = VerticalAlignment.Center + }; + _gear = CreateGear(automationId, field.Label ?? field.Field ?? automationId); + Content = new StackPanel + { + Orientation = Orientation.Horizontal, + Spacing = 6, + Children = { _valueText, _gear } + }; IsEnabled = editContext != null && field.IsEditable; AutomationProperties.SetAutomationId(this, automationId); AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); + // The gear (and only the gear) hides until hover; the button itself is a hover source. + // The region view widens the hover surface to the whole row (label included). + HoverReveal.Attach(new Control[] { this }, HoverAffordances); + if (editContext == null || !field.IsEditable) return; @@ -191,7 +322,10 @@ public FwChooserField( }; AutomationProperties.SetAutomationId(list, automationId + ".Options"); - var flyout = new Flyout { Content = list, Placement = PlacementMode.BottomEdgeAlignedLeft }; + var flyout = new Flyout { Placement = PlacementMode.BottomEdgeAlignedLeft }; + // B7: the layout's chooserLink jump links render below the options, like the legacy + // chooser dialog's link labels; without links the content stays the bare options list. + flyout.Content = RegionLinkChrome.WithChooserLinks(list, field, automationId, linkRequested, flyout); Flyout = flyout; list.SelectionChanged += (s, e) => @@ -208,7 +342,7 @@ public FwChooserField( if (editContext.TrySetOption(field, option.Key)) { _selectedKey = option.Key; - Content = option.Name; + _valueText.Text = option.Name; } flyout.Hide(); @@ -216,14 +350,33 @@ public FwChooserField( }; } + // Restyled chrome only — the control keeps the Button theme (template, flyout-on-click, + // focus, automation peer), not a lookup by this derived type's key. + protected override Type StyleKeyOverride => typeof(Button); + /// The currently selected option key (staged or initial). public string SelectedKey => _selectedKey; + /// The display text of the current selection (what the value TextBlock shows). + public string ValueText => _valueText.Text; + + /// The settings gear is the chooser's only hover-revealed affordance. + public IReadOnlyList HoverAffordances => new[] { _gear }; + private static string CurrentName(LexicalEditRegionField field) { var selected = field.Options.FirstOrDefault(o => o.Key == field.SelectedOptionKey); return selected?.Name ?? string.Empty; } + + // The shared gear icon (RegionChrome) with this row's automation identity. + private static Control CreateGear(string automationId, string label) + { + var gear = RegionChrome.CreateGearIcon(); + AutomationProperties.SetAutomationId(gear, automationId + ".Settings"); + AutomationProperties.SetName(gear, string.Format(FwAvaloniaStrings.FieldSettingsFormat, label)); + return gear; + } } /// @@ -233,15 +386,32 @@ private static string CurrentName(LexicalEditRegionField field) /// lists the possibility tree indented by (the legacy /// chooser tree; virtualized ListBox so the ~1800-node semantic-domain list stays usable). /// Right-clicking an item offers Remove. Without an edit context the row is read-only display. + /// Chrome (hover-reveal polish): the separator bars, the "+" launcher, and the settings gear + /// (which opens the SAME flyout as the "+") fade in on row hover only — items/text stay always + /// visible; flyout, staging, and automation ids are unchanged. /// - public sealed class FwReferenceVectorField : StackPanel + public sealed class FwReferenceVectorField : StackPanel, IHoverAffordanceProvider { + private readonly List _affordances = new List(); + + /// + /// (optional, like the other field callbacks): invoked + /// after a SUCCESSFUL add/remove stage, so the host view can commit the gesture immediately + /// — legacy commits each chooser-dialog gesture as it lands, and the row's Items are a + /// compose-time snapshot, so without a commit + re-show nothing visibly changes. + /// Failed stages never fire it. + /// public FwReferenceVectorField( LexicalEditRegionField field, string automationId, - IRegionEditContext editContext) + IRegionEditContext editContext, + Action gestureCompleted = null, + Action linkRequested = null) { Orientation = Orientation.Horizontal; + // 14.2-style hit-testing rule: a null background only hit-tests the glyphs — the WHOLE + // row must receive hover so the reveal chrome works over the gaps between items. + Background = Brushes.Transparent; AutomationProperties.SetAutomationId(this, automationId); AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); @@ -252,27 +422,39 @@ public FwReferenceVectorField( { Text = item.Name, VerticalAlignment = VerticalAlignment.Center, - Margin = new Thickness(0, 0, 4, 0) + Margin = new Thickness(0, 0, 4, 0), + // 14.2: a null background only hit-tests the glyphs — the whole item must take + // the right-click or the Remove flyout only opens over ink. + Background = Brushes.Transparent }; AutomationProperties.SetAutomationId(text, automationId + ".Item." + item.Key); if (editable) { var removeItem = new MenuItem { Header = FwAvaloniaStrings.Remove }; var key = item.Key; - removeItem.Click += (s, e) => editContext.TryRemoveReferenceItem(field, key); + removeItem.Click += (s, e) => + { + // Only a successful stage completes the gesture (commit + host re-show). + if (editContext.TryRemoveReferenceItem(field, key)) + gestureCompleted?.Invoke(); + }; text.ContextFlyout = new MenuFlyout { Items = { removeItem } }; } Children.Add(text); - Children.Add(SeparatorBar()); + AddSeparatorBar(); } if (!editable) + { + // Read-only rows still get the hover-reveal chrome for their separator bars. + HoverReveal.Attach(new Control[] { this }, _affordances); return; + } // The legacy empty add slot: a trailing bar (added above for the last item; one leads // the launcher when the vector is empty) plus the chooser launcher. if (field.Items.Count == 0) - Children.Add(SeparatorBar()); + AddSeparatorBar(); var addButton = new Button { @@ -326,40 +508,72 @@ public FwReferenceVectorField( }; } - var flyout = new Flyout { Content = flyoutContent, Placement = PlacementMode.BottomEdgeAlignedLeft }; + var flyout = new Flyout { Placement = PlacementMode.BottomEdgeAlignedLeft }; + // B7: the layout's chooserLink jump links render below the options/search, like the + // legacy chooser dialog's link labels. + flyout.Content = RegionLinkChrome.WithChooserLinks(flyoutContent, field, automationId, + linkRequested, flyout); addButton.Flyout = flyout; list.SelectionChanged += (s, e) => { - if (list.SelectedItem is RegionChoiceOption option) - editContext.TryAddReferenceItem(field, option.Key); + var added = list.SelectedItem is RegionChoiceOption option + && editContext.TryAddReferenceItem(field, option.Key); flyout.Hide(); list.SelectedItem = null; addButton.Focus(); // popup focus return, like the chooser + // Only a successful stage completes the gesture (commit + host re-show). + if (added) + gestureCompleted?.Invoke(); }; Children.Add(addButton); + _affordances.Add(addButton); + + // The hover-revealed settings gear (the "this value has a supporting list" affordance, + // identical to the chooser's): it opens the SAME options/add flyout as the "+". + var gearButton = RegionChrome.CreateGearButton(); + gearButton.Flyout = flyout; + AutomationProperties.SetAutomationId(gearButton, automationId + ".Settings"); + AutomationProperties.SetName(gearButton, string.Format(FwAvaloniaStrings.FieldSettingsFormat, + field.Label ?? field.Field ?? automationId)); + Children.Add(gearButton); + _affordances.Add(gearButton); + + // Bars, launcher, and gear hide until hover; the whole field panel is a hover source + // (the region view widens the surface to the row's label too). Items stay always visible. + HoverReveal.Attach(new Control[] { this }, _affordances); } + /// The separator bars, "+" launcher, and gear reveal on row hover (chrome only). + public IReadOnlyList HoverAffordances => _affordances; + // The legacy VwSeparatorBox: a ~2px, font-height, light grey vertical bar after each item // (and fronting the add slot) — the affordance that marks where content can be added. - private static Control SeparatorBar() => new Border + private void AddSeparatorBar() { - Width = 2, - Height = 14, - Background = Brushes.LightGray, - Margin = new Thickness(2, 0, 6, 0), - VerticalAlignment = VerticalAlignment.Center - }; + var bar = new Border + { + Width = 2, + Height = 14, + Background = Brushes.LightGray, + Margin = new Thickness(2, 0, 6, 0), + VerticalAlignment = VerticalAlignment.Center + }; + Children.Add(bar); + _affordances.Add(bar); + } } /// /// FieldWorks-owned dialog-launcher row (winforms-free-lexeme-editor.md D4): the legacy /// *DlgLauncherSlice pattern — the field's current value as read-only text plus the - /// trailing "..." launcher button. The button invokes a host-injected callback (the - /// ILegacyDialogLauncher seam on the xWorks side; this layer stays LCModel-free, so the - /// callback is a plain delegate). Without a callback the button renders DISABLED with an - /// explanatory tooltip — the value still shows, the affordance is visibly unavailable. + /// trailing launcher button, now the SAME hover-revealed settings gear the chooser and + /// reference vector draw (it replaced the always-visible legacy "..."). The button invokes a + /// host-injected callback (the ILegacyDialogLauncher seam on the xWorks side; this layer stays + /// LCModel-free, so the callback is a plain delegate). Without a callback the gear renders + /// DISABLED with an explanatory tooltip — the value still shows, the affordance is visibly + /// unavailable once hover reveals it. /// - public sealed class FwDialogLauncherField : DockPanel + public sealed class FwDialogLauncherField : DockPanel, IHoverAffordanceProvider { private readonly Action _launch; private readonly Button _button; @@ -369,22 +583,19 @@ public FwDialogLauncherField(string value, string label, Action launch) _launch = launch; Value = value ?? string.Empty; LastChildFill = true; + // 14.2: a null background only hit-tests the glyphs — the WHOLE row must receive hover + // so the gear reveal works over the gaps. + Background = Brushes.Transparent; AutomationProperties.SetName(this, label ?? string.Empty); - // The legacy ButtonLauncher ellipsis button, docked at the row's end like m_panel. - _button = new Button - { - Content = "...", - Padding = new Thickness(6, 0, 6, 0), - MinHeight = 0, - MinWidth = 0, - VerticalAlignment = VerticalAlignment.Center, - IsEnabled = launch != null - }; + // The legacy ButtonLauncher launch affordance, docked at the row's end like m_panel — + // drawn as the shared settings gear, hover-revealed like the chooser/vector ones. + _button = RegionChrome.CreateGearButton(); + _button.IsEnabled = launch != null; AutomationProperties.SetName(_button, FwAvaloniaStrings.LaunchDialog); if (launch == null) { - // D4 degradation: no host dialog service — the button shows but cannot launch. + // D4 degradation: no host dialog service — the gear shows but cannot launch. ToolTip.SetTip(_button, FwAvaloniaStrings.LauncherUnavailable); } _button.Click += (s, e) => Launch(); @@ -394,15 +605,23 @@ public FwDialogLauncherField(string value, string label, Action launch) Text = value ?? string.Empty, VerticalAlignment = VerticalAlignment.Center, TextWrapping = TextWrapping.Wrap, - Margin = new Thickness(0, 0, 6, 0) + Margin = new Thickness(0, 0, 6, 0), + Background = Brushes.Transparent // 14.2 again: the value text is the hover surface }; AutomationProperties.SetName(text, label ?? string.Empty); DockPanel.SetDock(_button, Dock.Right); Children.Add(_button); Children.Add(text); + + // The gear hides until hover; the whole row panel is a hover source (the region view + // widens the surface to the row's label too, via IHoverAffordanceProvider). + HoverReveal.Attach(new Control[] { this }, HoverAffordances); } + /// The launch gear is the row's only hover-revealed affordance. + public IReadOnlyList HoverAffordances => new[] { (Control)_button }; + /// The displayed value text (the legacy launcher view's rendering). public string Value { get; } diff --git a/Src/Common/FwAvalonia/Region/HoverReveal.cs b/Src/Common/FwAvalonia/Region/HoverReveal.cs new file mode 100644 index 0000000000..542c2cb720 --- /dev/null +++ b/Src/Common/FwAvalonia/Region/HoverReveal.cs @@ -0,0 +1,199 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Animation; +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace SIL.FieldWorks.Common.FwAvalonia.Region +{ + /// + /// A field editor whose chrome includes hover-revealed affordances (the chooser's settings + /// gear, the reference vector's separator bars and "+" launcher). The region view reads this + /// to widen the hover surface to the WHOLE row (label + editor) — chrome only, no behavior. + /// + public interface IHoverAffordanceProvider + { + /// The controls revealed on row hover; empty when the field has none. + IReadOnlyList HoverAffordances { get; } + } + + /// + /// Modern hover-reveal chrome for secondary affordances: the affordances start hidden by + /// OPACITY (they stay in layout — rows never reflow — and stay in the UIA tree, focusable), + /// fade in (~120ms) while the pointer is over any hover source or any affordance (entering + /// the gear itself must not flicker it away), and fade out when the pointer leaves them all. + /// Keyboard access: an affordance gaining focus (Tab) also reveals; losing focus hides again + /// unless the pointer is over. Pure chrome — no flyout, staging, or automation-id changes. + /// + public static class HoverReveal + { + /// The opacity fade duration (the "modern feel" transition). + internal static readonly TimeSpan FadeDuration = TimeSpan.FromMilliseconds(120); + + /// + /// Wires to reveal while the pointer is over any of + /// (or over an affordance itself) and hide otherwise. + /// Idempotent per affordance: attaching again (the view widening the hover surface to the + /// row after the control wired itself) only adds the new sources. + /// + public static void Attach(IReadOnlyList hoverSources, IReadOnlyList affordances) + { + var targets = (affordances ?? Array.Empty()).Where(a => a != null).Distinct().ToList(); + if (targets.Count == 0) + return; + var sources = (hoverSources ?? Array.Empty()).Where(s => s != null).Distinct().ToList(); + + foreach (var affordance in targets) + { + // One opacity transition per affordance, even across repeated Attach calls. + var transitions = affordance.Transitions ?? (affordance.Transitions = new Transitions()); + if (!transitions.OfType().Any(t => t.Property == Visual.OpacityProperty)) + { + transitions.Add(new DoubleTransition + { + Property = Visual.OpacityProperty, + Duration = FadeDuration + }); + } + } + SetRevealed(targets, false); + + // The affordances are hover sources too: moving onto the gear keeps it revealed. + var watched = sources.Concat(targets).Distinct().ToList(); + + void Update() + { + var reveal = watched.Any(c => c.IsPointerOver) || targets.Any(a => a.IsFocused); + SetRevealed(targets, reveal); + } + + foreach (var control in watched) + { + control.PointerEntered += (s, e) => Update(); + control.PointerExited += (s, e) => Update(); + } + + foreach (var affordance in targets) + { + // Accessibility: opacity-hidden affordances stay focusable, so Tab reveals them. + affordance.GotFocus += (s, e) => SetRevealed(targets, true); + affordance.LostFocus += (s, e) => Update(); + } + } + + /// Applies the revealed/hidden state: opacity plus hit-test visibility. + internal static void SetRevealed(IEnumerable affordances, bool revealed) + { + foreach (var affordance in affordances) + { + affordance.Opacity = revealed ? 1d : 0d; + affordance.IsHitTestVisible = revealed; + } + } + } + + /// + /// The shared hover-affordance chrome: every field whose value has a supporting list/dialog + /// (chooser, reference vector, dialog launcher) draws the IDENTICAL settings-gear icon from + /// this one factory, so the affordance reads the same across all rows. + /// + internal static class RegionChrome + { + // A real cog drawn as geometry (circle + teeth + hub hole, even-odd fill), not a text/emoji + // glyph: 8 teeth on a 24-unit canvas rendered at ~14px in the muted ws-abbreviation hue. + private static readonly Geometry GearGeometry = CreateGearGeometry(); + + /// The gear icon itself, for hosts that carry it inside their own click surface. + internal static Control CreateGearIcon() + => new Avalonia.Controls.Shapes.Path + { + Data = GearGeometry, + Fill = PocDensity.WsAbbrevBrush, + Width = 14, + Height = 14, + Stretch = Stretch.Uniform, + VerticalAlignment = VerticalAlignment.Center + }; + + /// A flat (transparent, borderless) button carrying the gear icon as its face. + internal static Button CreateGearButton() + => new Button + { + Content = CreateGearIcon(), + Padding = new Thickness(4, 0, 4, 0), + MinHeight = 0, + MinWidth = 0, + Background = Brushes.Transparent, + BorderThickness = new Thickness(0), + VerticalAlignment = VerticalAlignment.Center + }; + + // Built from MODEL segments (PathGeometry/ArcSegment/LineSegment), NOT StreamGeometry.Open: + // opening a stream context demands the IPlatformRenderInterface, and xWorks hosts construct + // these controls in plain unit tests with no Avalonia platform loaded — model geometry only + // touches the platform when actually rendered. + private static Geometry CreateGearGeometry() + { + const int teeth = 8; + const double cx = 12, cy = 12; + const double tipRadius = 11, bodyRadius = 8, holeRadius = 3.6; + var step = Math.PI * 2 / teeth; + Point Polar(double radius, double angle) + => new Point(cx + radius * Math.Cos(angle), cy + radius * Math.Sin(angle)); + PathSegment Line(Point point) => new LineSegment { Point = point }; + PathSegment Arc(Point point, double radius) => new ArcSegment + { + Point = point, + Size = new Size(radius, radius), + SweepDirection = SweepDirection.Clockwise + }; + + // Toothed ring: per tooth, rise from the body circle to the tip, across, and back, + // then an arc along the body to the next tooth. + var ring = new PathFigure + { + StartPoint = Polar(bodyRadius, -step * 0.28), + IsClosed = true, + IsFilled = true, + Segments = new PathSegments() + }; + for (var i = 0; i < teeth; i++) + { + var a = i * step; + if (i > 0) + ring.Segments.Add(Arc(Polar(bodyRadius, a - step * 0.28), bodyRadius)); + ring.Segments.Add(Line(Polar(tipRadius, a - step * 0.14))); + ring.Segments.Add(Arc(Polar(tipRadius, a + step * 0.14), tipRadius)); + ring.Segments.Add(Line(Polar(bodyRadius, a + step * 0.28))); + } + ring.Segments.Add(Arc(Polar(bodyRadius, -step * 0.28), bodyRadius)); + + // Hub hole (even-odd makes it a cut-out). + var hub = new PathFigure + { + StartPoint = new Point(cx + holeRadius, cy), + IsClosed = true, + IsFilled = true, + Segments = new PathSegments + { + Arc(new Point(cx - holeRadius, cy), holeRadius), + Arc(new Point(cx + holeRadius, cy), holeRadius) + } + }; + + return new PathGeometry + { + FillRule = FillRule.EvenOdd, + Figures = new PathFigures { ring, hub } + }; + } + } +} diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs index a24796dd2e..a94dce0eb8 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs @@ -110,6 +110,53 @@ public RegionChoiceOption(string key, string name, int depth = 0) public int Depth { get; } } + /// + /// A list-editor jump link on a chooser/reference-vector row (B7): the legacy chooser dialog's + /// "Edit the … list" LinkLabel (ReallySimpleListChooser.AddLink with + /// LinkType.kGotoLink), composed from the layout's chooserLink type="goto" + /// metadata. Clicking it asks the host to jump to the tool that edits the underlying list. + /// + public sealed class RegionChooserLink + { + public RegionChooserLink(string label, string tool, string targetGuid = null) + { + Label = label; + Tool = tool; + TargetGuid = targetGuid; + } + + /// The localized link text (e.g. "Edit the Publications list"). + public string Label { get; } + + /// The destination tool (e.g. publicationsEdit) of the legacy FwLinkArgs jump. + public string Tool { get; } + + /// + /// The jump's target object guid string, or null for a plain tool jump — the legacy chooser + /// passes Guid.Empty (m_guidLink) unless a flidTextParam resolved one, + /// and none of the lexeme-editor parts carry that. + /// + public string TargetGuid { get; } + } + + /// + /// A request to follow a chooser jump link (B7): the host dispatches it the way the legacy + /// chooser does on link click — mediator FollowLink with FwLinkArgs(tool, target) + /// (ReallySimpleListChooser.HandleAnyJump). + /// + public sealed class RegionLinkRequest + { + public RegionLinkRequest(LexicalEditRegionField field, RegionChooserLink link) + { + Field = field; + Link = link; + } + + public LexicalEditRegionField Field { get; } + + public RegionChooserLink Link { get; } + } + /// /// A field on a lexical-edit region, projected from a typed and bound to live /// values by an . This is the product contract that replaces the @@ -142,8 +189,10 @@ public LexicalEditRegionField( string ghostPrompt = null, IReadOnlyList items = null, Func controlFactory = null, - Func> searchOptions = null) + Func> searchOptions = null, + IReadOnlyList chooserLinks = null) { + ChooserLinks = chooserLinks ?? new List(); Items = items ?? new List(); ControlFactory = controlFactory; SearchOptions = searchOptions; @@ -237,6 +286,14 @@ public LexicalEditRegionField( /// , a plain delegate keeps this layer LCModel-free. /// public Func> SearchOptions { get; } + + /// + /// The list-editor jump links of a chooser/reference-vector row (B7): composed from the + /// layout's chooserLink type="goto" metadata (e.g. "Edit the Publications list" → + /// publicationsEdit). The gear flyout surfaces them below the options; clicking raises the + /// host's RegionLinkRequest callback. Empty for rows without chooser metadata. + /// + public IReadOnlyList ChooserLinks { get; } } /// Which legacy menu lane a right-click maps to (section 13). diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs index d679bc71eb..00084e8937 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs @@ -38,6 +38,7 @@ public sealed class LexicalEditRegionView : UserControl private readonly Func _getExpansionState; private readonly Action _expansionChanged; private readonly Action _menuRequested; + private readonly Action _linkRequested; /// /// Optional expansion-state hooks (11.8): supplies the @@ -49,7 +50,8 @@ public LexicalEditRegionView(LexicalEditRegionModel model, IRegionEditContext ed Action writingSystemFocused = null, Func getExpansionState = null, Action expansionChanged = null, - Action menuRequested = null) + Action menuRequested = null, + Action linkRequested = null) { Model = model ?? throw new ArgumentNullException(nameof(model)); _editContext = editContext; @@ -57,6 +59,7 @@ public LexicalEditRegionView(LexicalEditRegionModel model, IRegionEditContext ed _getExpansionState = getExpansionState; _expansionChanged = expansionChanged; _menuRequested = menuRequested; + _linkRequested = linkRequested; Name = "LexicalEditRegionView"; AutomationProperties.SetAutomationId(this, "LexicalEditRegionView"); @@ -306,6 +309,11 @@ private void AddField(Grid grid, int row, LexicalEditRegionField field) Grid.SetColumn(editor, 2); grid.Children.Add(editor); _rowControls[row].Add(editor); + + // Hover-reveal chrome: the WHOLE row is the hover surface for an editor's secondary + // affordances (chooser gear, vector bars/launcher) — hovering the label reveals too. + if (editor is IHoverAffordanceProvider provider && provider.HoverAffordances.Count > 0) + HoverReveal.Attach(new Control[] { labelBlock, editor }, provider.HoverAffordances); } // Section 13: right-click on a label/header surfaces the legacy slice menu (or the section's @@ -387,7 +395,14 @@ private Control BuildEditor(LexicalEditRegionField field, string automationId) case RegionFieldKind.Custom: return BuildCustom(field, automationId); case RegionFieldKind.ReferenceVector: - return new FwReferenceVectorField(field, automationId, _editContext); + // Reference add/remove gestures commit immediately (legacy chooser-dialog + // behavior): the staged session would otherwise sit open — LCModel broadcasts + // PropChanged only at EndUndoTask and the row's Items are a compose-time + // snapshot, so the user would see no change. The gesture-completed callback + // runs the SAME validation-gated OnSave the focus-loss autosave uses, whose + // EditCompleted re-show rebuilds the row from domain truth. + return new FwReferenceVectorField(field, automationId, _editContext, + _editContext == null ? (Action)null : OnSave, _linkRequested); case RegionFieldKind.Chooser: return BuildChooser(field, automationId); case RegionFieldKind.Boolean: @@ -477,7 +492,7 @@ private Control BuildText(LexicalEditRegionField field, string automationId) => new FwMultiWsTextField(field, automationId, _editContext, _writingSystemFocused, _menuRequested); private Control BuildChooser(LexicalEditRegionField field, string automationId) - => new FwChooserField(field, automationId, _editContext); + => new FwChooserField(field, automationId, _editContext, _linkRequested); // winforms-free-lexeme-editor.md D1: a plugin-claimed custom slice renders its plugin's own // Avalonia control in the value column, at the slice's real position. Guarded lane: a diff --git a/Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs b/Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs index 6fc48d8328..b052d06438 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs @@ -43,11 +43,14 @@ public static class LayoutImportCoverage "if", "ifnot", "choice", "where", "otherwise" }; + // B7: chooserLink imports as typed metadata; chooserInfo is handled as the link container + // (its OTHER attributes — title/text/guicontrol/… — stay measured as unhandled below). private static readonly HashSet HandledPartsFileElements = new HashSet(StringComparer.Ordinal) { "PartInventory", "bin", "part", "slice", "seq", "obj", - "if", "ifnot", "choice", "where", "otherwise" + "if", "ifnot", "choice", "where", "otherwise", + "chooserInfo", "chooserLink" }; /// @@ -171,6 +174,13 @@ private static bool IsAttributeHandled(string elementName, string attributeName, // B3: the parsed condition vocabulary; publishing-only condition forms // (stringaltequals etc.) stay measured as unhandled. return XmlLayoutImporter.HandledConditionAttributes.Contains(attributeName); + case "chooserLink": + // B7: the typed jump-link vocabulary. + return XmlLayoutImporter.HandledChooserLinkAttributes.Contains(attributeName); + case "chooserInfo": + // B7 remainder: only the chooserLink children import; chooserInfo's own + // attributes (title/text/textparam/flidTextParam/guicontrol/helpBrowser) do not. + return false; case "choice": case "otherwise": return false; // these carry no attributes in the shipped files diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs index 949d710b89..c041ef4dc1 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionJsonSerializer.cs @@ -96,11 +96,37 @@ private static JObject WriteNode(ViewNode node) AddIfPresent(o, "ghostInitMethod", node.GhostInitMethod); if (node.Condition != null) o["condition"] = WriteCondition(node.Condition); + // B7: the chooser jump-link block, reserved in the canonical schema (xml-retirement-blockers + // cross-cutting deadline) — label/tool/type/target exactly as the legacy chooserLink carries. + if (node.ChooserLinks.Count > 0) + o["chooserLinks"] = new JArray(node.ChooserLinks.Select(WriteChooserLink)); if (node.Children.Count > 0) o["children"] = new JArray(node.Children.Select(WriteNode)); return o; } + private static JObject WriteChooserLink(ViewChooserLink link) + { + var o = new JObject(); + // "goto" is the legacy default; anything else must be explicit. + if (!string.Equals(link.Type, "goto", StringComparison.Ordinal)) + o["type"] = link.Type; + AddIfPresent(o, "label", link.Label); + AddIfPresent(o, "tool", link.Tool); + AddIfPresent(o, "target", link.Target); + return o; + } + + private static ViewChooserLink ReadChooserLink(JToken token) + { + var o = (JObject)token; + return new ViewChooserLink( + (string)o["type"], + (string)o["label"], + (string)o["tool"], + (string)o["target"]); + } + // B3: the structured conditional-display metadata (legacy //), reserved in // the canonical schema before Layer-1 freezes (xml-retirement-blockers, cross-cutting deadline). private static JObject WriteCondition(ViewCondition condition) @@ -183,7 +209,8 @@ private static ViewNode ReadNode(JToken token) (string)o["ghostClass"], (string)o["ghostLabel"], ghostInitMethod: (string)o["ghostInitMethod"], - condition: ReadCondition((JObject)o["condition"])); + condition: ReadCondition((JObject)o["condition"]), + chooserLinks: ((JArray)o["chooserLinks"])?.Select(ReadChooserLink).ToList()); } private static T ParseEnum(JObject o, string name, T fallback) where T : struct diff --git a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs index 86e0c06319..da00e0f650 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs @@ -281,6 +281,41 @@ void Append(string name, string value) } } + /// + /// A chooser jump link imported from the legacy <chooserInfo><chooserLink> + /// element (B7, xml-retirement-blockers): the cross-tool "Edit the … list" link the legacy + /// chooser dialog shows (ReallySimpleListChooser.InitializeExtras, + /// ReallySimpleListChooser.cs:887-926). Attributes are preserved verbatim: + /// defaults to "goto" like the legacy reader; carries the rare + /// target= attribute ("TopPOS") that only the grammar-area "simple" link consumes. + /// + public sealed class ViewChooserLink + { + public ViewChooserLink(string type, string label, string tool, string target = null) + { + Type = string.IsNullOrEmpty(type) ? "goto" : type; + Label = label; + Tool = tool; + Target = target; + } + + /// Legacy type= ("goto"/"dialog"/"simple"); defaults to "goto" like the legacy reader. + public string Type { get; } + + /// Legacy label=, the link text (localized through the StringTable lane at compose time). + public string Label { get; } + + /// Legacy tool=, the destination tool of the FwLinkArgs jump (e.g. publicationsEdit). + public string Tool { get; } + + /// Legacy target= (rare; "TopPOS" in the grammar area). Null when absent. + public string Target { get; } + + /// Deterministic summary used by . + public override string ToString() + => $"{Type}:{Tool}:{Label}{(string.IsNullOrEmpty(Target) ? string.Empty : ":" + Target)}"; + } + /// /// An immutable typed view-definition node. This is the framework-neutral migration contract that /// both the legacy WinForms adapter and the future Avalonia adapter consume instead of raw XML. @@ -317,7 +352,8 @@ public ViewNode( string customEditorClass = null, string customEditorAssembly = null, string ghostInitMethod = null, - ViewCondition condition = null) + ViewCondition condition = null, + IReadOnlyList chooserLinks = null) { StableId = stableId; Kind = kind; @@ -348,6 +384,7 @@ public ViewNode( CustomEditorAssembly = customEditorAssembly; GhostInitMethod = ghostInitMethod; Condition = condition; + ChooserLinks = chooserLinks ?? (IReadOnlyList)Array.Empty(); } /// Deterministic identity derived from the node's path (stable across realizations). @@ -447,6 +484,12 @@ public ViewNode( /// which renders when no sibling condition passed). Evaluated per object at compose time. /// public ViewCondition Condition { get; } + + /// + /// The chooser jump links from the slice's <chooserInfo> (B7), in document order + /// (legacy shows at most two). Empty for nodes without chooser metadata. + /// + public IReadOnlyList ChooserLinks { get; } } /// @@ -531,6 +574,9 @@ private static string AppendMetadata(ViewNode node) // rides the snapshot — JSON round-trip equality fails if condition metadata is dropped. if (node.Condition != null) sb.Append($" | cond=[{node.Condition}]"); + // B7: chooser links likewise ride the snapshot so a lossy round trip fails loudly. + if (node.ChooserLinks.Count > 0) + sb.Append($" | links=[{string.Join(";", node.ChooserLinks)}]"); return sb.ToString(); } } diff --git a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs index 8f31d1f695..1776324a1c 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs @@ -3,6 +3,7 @@ // (http://www.gnu.org/licenses/lgpl-2.1.html) using System.Collections.Generic; +using System.Linq; using System.Xml.Linq; namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition @@ -44,6 +45,12 @@ public sealed class XmlLayoutImporter : IViewDefinitionImporter "ghost", "ghostWs", "ghostClass", "ghostLabel", "ghostInitMethod" }; + // B7: the chooserLink attribute vocabulary the importer consumes (the legacy reader's exact + // set, ReallySimpleListChooser.cs:887-926). The shipped files carry type/label/tool on all 94 + // links and target on 2 (grammar-area slot links). + public static readonly HashSet HandledChooserLinkAttributes = + new HashSet(System.StringComparer.Ordinal) { "type", "label", "tool", "target" }; + // B3: the condition vocabulary the importer parses into ViewCondition — exactly the forms the // shipped DETAIL layouts use (audited 2026-06-11 over DistFiles .../Parts: boolequals 44, // intequals 9, lengthatleast/-most 8, intmemberof 2, intlessthan 5, guidequals 2, is/target on @@ -285,6 +292,7 @@ private ViewNode BuildNode( ReportUnhandledAttributes(contentEl, HandledSliceAttributes, "slice", stableId, diagnostics); ReportSubstitutionValues(contentEl, HandledSliceAttributes, stableId, diagnostics); + var chooserLinks = new List(); var childElements = new List(); foreach (var child in contentEl.Elements()) { @@ -292,6 +300,14 @@ private ViewNode BuildNode( { childElements.Add(child); } + else if (child.Name.LocalName == "chooserInfo") + { + // B7: the chooser jump links import as typed metadata (the legacy + // "Edit the … list" links, ReallySimpleListChooser.InitializeExtras); + // chooserInfo's other facets (title/text/guicontrol/textparam) are still + // reported, not silently dropped. + ImportChooserInfo(child, stableId, chooserLinks, diagnostics); + } else if (child.Name.LocalName != "properties") { // is consumed above (11.15 emphasis); the rest is reported. @@ -333,7 +349,8 @@ private ViewNode BuildNode( return new ViewNode(stableId, ViewNodeKind.Group, label, abbreviation, field, editor, classification, ws, visibility, expansion, indented, null, children, localizationKey, automationId, routing, boldEmphasis, fontScalePercent, - menuId, contextMenuId, hotlinksId); + menuId, contextMenuId, hotlinksId, + chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null); } // Dynamic custom slices keep their legacy class/assembly identity so the host can @@ -345,7 +362,8 @@ private ViewNode BuildNode( localizationKey, automationId, routing, boldEmphasis, fontScalePercent, menuId, contextMenuId, hotlinksId, customEditorClass: Attr(contentEl, "class"), - customEditorAssembly: Attr(contentEl, "assemblyPath")); + customEditorAssembly: Attr(contentEl, "assemblyPath"), + chooserLinks: chooserLinks.Count > 0 ? chooserLinks : null); } case "obj": case "seq": @@ -441,6 +459,40 @@ private ViewNode BuildNode( } } + // B7: import a slice's — the chooserLink jump links become typed metadata in + // document order, mirroring the legacy reader's attribute set exactly + // (ReallySimpleListChooser.cs:887-926: type defaults to "goto", label/tool/target verbatim). + // chooserInfo's OTHER facets (title/text/textparam/flidTextParam/guicontrol/helpBrowser) are + // not imported yet; they keep the slice-content-dropped report so the B7 remainder stays + // measured rather than silently lost. + private static void ImportChooserInfo( + XElement chooserInfoEl, string stableId, List chooserLinks, + List diagnostics) + { + foreach (var linkEl in chooserInfoEl.Elements("chooserLink")) + { + chooserLinks.Add(new ViewChooserLink( + Attr(linkEl, "type"), + Attr(linkEl, "label"), + Attr(linkEl, "tool"), + Attr(linkEl, "target"))); + ReportUnhandledAttributes(linkEl, HandledChooserLinkAttributes, "chooserLink", stableId, diagnostics); + } + + var droppedFacets = chooserInfoEl.Attributes() + .Select(a => a.Name.LocalName) + .Concat(chooserInfoEl.Elements() + .Where(e => e.Name.LocalName != "chooserLink") + .Select(e => "<" + e.Name.LocalName + ">")) + .ToList(); + if (droppedFacets.Count > 0) + { + diagnostics.Add(new ViewDiagnostic(ViewDiagnosticSeverity.Info, "slice-content-dropped", + $"Slice content child facets ({string.Join(", ", droppedFacets)}) are not imported; only chooserLink is.", + stableId)); + } + } + // B3: a conditional wrapper's children are part content (//, possibly nested // conditionals) inside part definitions, or / refs at layout level — exactly the // child kinds DataTree.ProcessPartChildren dispatches. diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs index 101e2f83be..9e362fce79 100644 --- a/Src/xWorks/FullEntryRegionComposer.cs +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -486,6 +486,36 @@ private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) null, null, menuId: "mnuDataTree-Help"); } + // B7: project the node's imported chooserLink metadata onto the row — the legacy + // chooser dialog's "Edit the … list" jump links (ReallySimpleListChooser. + // InitializeExtras, ReallySimpleListChooser.cs:887-926). Only the "goto" kind is + // implemented: it is the ONLY kind the lexeme-editor layouts use (all 95 shipped + // chooserLinks are type="goto"); legacy "dialog"/"simple" links need ChooserCommand + // lanes and are logged + skipped, never half-dispatched. The target guid stays empty + // like legacy m_guidLink (no lexeme-editor chooserInfo sets flidTextParam); labels + // localize through the same StringTable lane as XmlUtils.GetLocalizedAttributeValue. + private IReadOnlyList BuildChooserLinks(ViewNode node) + { + if (node.ChooserLinks.Count == 0) + return null; + + List links = null; + foreach (var link in node.ChooserLinks) + { + if (!string.Equals(link.Type, "goto", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrEmpty(link.Label) || string.IsNullOrEmpty(link.Tool)) + { + System.Diagnostics.Debug.WriteLine( + $"chooserLink type '{link.Type}' (tool '{link.Tool}') on {node.StableId} is not the goto kind the lexeme editor uses; skipped."); + continue; + } + (links ?? (links = new List())) + .Add(new RegionChooserLink(Localize(link.Label), link.Tool)); + } + + return links; + } + // The three section-header construction sites (group header, summary slice, sequence // banner) build the identical collapsible header row; one helper keeps them from drifting. private void AddHeader(ViewNode node, ICmObject obj, int depth, string label) @@ -721,7 +751,8 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) node.WritingSystem, RegionFieldKind.Chooser, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, options, form.MorphTypeRA?.Guid.ToString(), isEditable: true, indent: depth, - menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo)); + menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo, + chooserLinks: BuildChooserLinks(node))); OptionSetters[stableId] = optionKey => { @@ -791,7 +822,7 @@ private void AddAtomicPossibilityChooser(ViewNode node, ICmObject obj, int depth node.WritingSystem, RegionFieldKind.Chooser, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, options, selected, isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, - objectHvo: obj.Hvo)); + objectHvo: obj.Hvo, chooserLinks: BuildChooserLinks(node))); var hvo = obj.Hvo; OptionSetters[stableId] = key => @@ -824,7 +855,8 @@ private void AddReferenceVector(ViewNode node, ICmObject obj, int depth, int fli node.WritingSystem, RegionFieldKind.ReferenceVector, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, options, null, isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, - hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items)); + hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items, + chooserLinks: BuildChooserLinks(node))); var hvo = obj.Hvo; ReferenceAddSetters[stableId] = key => @@ -927,7 +959,8 @@ private void AddEntryReferenceVector(ViewNode node, ICmObject obj, int depth, in node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items, - searchOptions: query => SearchLexicon(query, hvo, flid, owningEntry))); + searchOptions: query => SearchLexicon(query, hvo, flid, owningEntry), + chooserLinks: BuildChooserLinks(node))); ReferenceAddSetters[stableId] = key => { diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index 1fcaeb1886..25498e5b32 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -705,7 +705,7 @@ private void ShowAvaloniaEntry(ICmObject obj) m_avaloniaEntryForm.ShowRegion(region, editContext, wsTag => LexicalEditRegionBuilder.ActivateKeyboardForWritingSystem(Cache, wsTag), GetPersistedExpansionState, PersistExpansionState, - OnRegionMenuRequested); + OnRegionMenuRequested, OnRegionLinkRequested); } /// @@ -881,6 +881,44 @@ private void OnRegionMenuRequested(RegionMenuRequest request) } } + /// + /// B7: follows a chooser jump link (e.g. "Edit the Publications list" on Publish In) the + /// EXACT way the legacy chooser does on link click — the dialog closes, then + /// ReallySimpleListChooser.HandleAnyJump posts FollowLink with the + /// FwLinkArgs(tool, guid) built from the layout's chooserLink + /// (ReallySimpleListChooser.cs:900/1657). Here the flyout has already closed; any open + /// fenced edit session settles first (the jump navigates away from this record), then the + /// same mediator message posts. + /// + private void OnRegionLinkRequested(RegionLinkRequest request) + { + try + { + m_regionEditContext.Settle(); +#pragma warning disable 618 // legacy parity: ReallySimpleListChooser.HandleAnyJump posts the same way + m_mediator.PostMessage("FollowLink", BuildFollowLinkArgs(request)); +#pragma warning restore 618 + } + catch (Exception e) + { + Debug.WriteLine("Region chooser link jump failed: " + e); + } + } + + /// + /// The legacy translation: new FwLinkArgs(sTool, m_guidLink) — the tool from the + /// layout's chooserLink, the target guid empty unless the link resolved one (none of the + /// lexeme-editor chooserInfos set flidTextParam, so empty mirrors legacy exactly). + /// Internal so the mapping is unit-testable without a mediator. + /// + internal static FwLinkArgs BuildFollowLinkArgs(RegionLinkRequest request) + { + var target = Guid.Empty; + if (!string.IsNullOrEmpty(request.Link.TargetGuid)) + Guid.TryParse(request.Link.TargetGuid, out target); + return new FwLinkArgs(request.Link.Tool, target); + } + // Approved baseline adapter "command-menu-routing" (13.4): the hidden legacy DataTree + // DTMenuHandler provide the colleague chain and CurrentSlice context the legacy command // handlers require. Created lazily on first right-click; never attached/visible while the diff --git a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs index 2f2536c755..be2e66ad61 100644 --- a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs +++ b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs @@ -115,6 +115,17 @@ private void EnsureLists() Cache.LangProject.AnthroListOA.PossibilitiesOS.Add(anthro); anthro.Name.SetAnalysisDefaultWritingSystem("Kinship"); } + + // B7: the Publications list behind Publish In / Show As Headword In — the field whose + // legacy chooser carries the "Edit the Publications list" jump link. + if (Cache.LangProject.LexDbOA.PublicationTypesOA == null) + Cache.LangProject.LexDbOA.PublicationTypesOA = listFactory.Create(); + if (Cache.LangProject.LexDbOA.PublicationTypesOA.PossibilitiesOS.Count == 0) + { + var mainDictionary = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.PublicationTypesOA.PossibilitiesOS.Add(mainDictionary); + mainDictionary.Name.SetAnalysisDefaultWritingSystem("Main Dictionary"); + } } private ComposedEntryRegion Compose(bool showHidden = false) @@ -285,6 +296,40 @@ public void Edit_AtomicChooser_RejectsKeysOutsideTheList_WithoutOpeningASession( Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusConfirmed)); } + // GAP 1 / B7: the layout's chooserLink metadata composes onto the row — LexEntryParts.xml:48-53 + // gives Publish In , the legacy chooser dialog's jump LinkLabel + // (ReallySimpleListChooser.cs:887-900: AddLink(label, kGotoLink, new FwLinkArgs(tool, + // m_guidLink)) with m_guidLink = Guid.Empty — no flidTextParam on this part). + [Test] + public void Compose_PublishIn_CarriesThePublicationsJumpLink_WithEmptyTarget() + { + var composed = Compose(); + var publishIn = composed.Model.Fields.Single(f => f.Field == "PublishIn" + && f.Kind == RegionFieldKind.ReferenceVector && f.ObjectHvo == m_entry.Hvo); + + Assert.That(publishIn.ChooserLinks, Has.Count.EqualTo(1), + "the Publish In row carries the layout's jump link"); + var link = publishIn.ChooserLinks[0]; + Assert.That(link.Label, Is.EqualTo("Edit the Publications list")); + Assert.That(link.Tool, Is.EqualTo("publicationsEdit")); + Assert.That(link.TargetGuid, Is.Null, + "legacy m_guidLink stays Guid.Empty for this link — a plain tool jump"); + } + + // GAP 1 control: a chooserInfo without links (MorphologyParts.xml:280-283, title only) + // composes a chooser row with NO jump links — the link lane is data-driven, never invented. + [Test] + public void Compose_MorphTypeChooser_HasNoJumpLinks() + { + var composed = Compose(); + var morphType = composed.Model.Fields + .Single(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser); + + Assert.That(morphType.ChooserLinks, Is.Empty, + "MoForm-Detail-MorphTypeBasic's chooserInfo carries only a title, no chooserLink"); + } + // B7: a chooserInfo guicontrol "...FlatList" spec means the legacy chooser presents the list // FLAT (e.g. PeopleFlatList, EnvironmentFlatList); the option builder honors it by emitting // depth-0 options while keeping document order. diff --git a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs index e40cf9fdc4..167e56f59f 100644 --- a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs +++ b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs @@ -333,6 +333,26 @@ public void Compose_WalksTheFullCompiledLayout_AcrossObjects() "the senses sequence renders a section header"); } + // GAP 2: the legacy Lexeme Form slice's button is its slice TREE-NODE MENU — MoForm-Detail- + // AsLexemeForm (MorphologyParts.xml:219-221) binds menu="mnuDataTree-LexemeForm" + // (DataTreeInclude.xml:336-341: Show in Concordance / Swap with Allomorph / Convert to + // Affix Process/Allomorph), not a chooser launcher. The composed Lexeme Form text row must + // carry that binding so the view can draw its hover gear and the host can show the SAME + // xCore menu — data-driven from `menu=`, not hardcoded to "LexemeForm". + [Test] + public void Compose_LexemeFormRow_CarriesItsLegacySliceMenuBinding() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var lexemeForm = composed.Model.Fields.Single(f => f.Field == "Form" + && f.Kind == RegionFieldKind.Text && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); + + Assert.That(lexemeForm.Label, Is.EqualTo("Lexeme Form")); + Assert.That(lexemeForm.MenuId, Is.EqualTo("mnuDataTree-LexemeForm"), + "the layout's slice menu rides the row — it feeds the hover gear AND label right-click"); + Assert.That(lexemeForm.ContextMenuId, Is.EqualTo("mnuDataTree-LexemeFormContext"), + "the in-string context menu binding survives alongside"); + } + // Review finding A: compiled definitions are memoized per (class, layout) for the lifetime // of the loaded sources — a repeat compose serves every layout from the memo instead of // rebuilding and re-fingerprinting the ~300KB parts snapshot per object per compose. diff --git a/Src/xWorks/xWorksTests/RegionEditLinkDispatchTests.cs b/Src/xWorks/xWorksTests/RegionEditLinkDispatchTests.cs new file mode 100644 index 0000000000..f107e18b2c --- /dev/null +++ b/Src/xWorks/xWorksTests/RegionEditLinkDispatchTests.cs @@ -0,0 +1,61 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.FieldWorks.Common.FwUtils; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// GAP 1 / B7 — the host's link dispatch: a chooser jump link translates to EXACTLY the legacy + /// chooser's FwLinkArgs (ReallySimpleListChooser.cs:900: + /// new FwLinkArgs(sTool, m_guidLink), with m_guidLink == Guid.Empty unless a + /// flidTextParam resolved a target). The mediator hop itself + /// (m_mediator.PostMessage("FollowLink", …), ReallySimpleListChooser.cs:1657) is one + /// obsolete-API call inside RecordEditView.OnRegionLinkRequested and needs a live + /// xCore mediator + FwXWindow to observe; it is exercised by the manual/UIA lanes, so the unit + /// seam here is the translation the message carries. + /// + [TestFixture] + public class RegionEditLinkDispatchTests + { + private static RegionLinkRequest Request(string tool, string targetGuid = null) + => new RegionLinkRequest( + new LexicalEditRegionField("LexEntry/Normal/#0@1", "Publish Entry In", "PublishIn", + null, RegionFieldKind.ReferenceVector, EditorClassification.Known, "PublishIn", + null, SurfaceRouting.Inherit, null, null, null), + new RegionChooserLink("Edit the Publications list", tool, targetGuid)); + + [Test] + public void BuildFollowLinkArgs_PlainToolJump_UsesGuidEmpty_LikeTheLegacyChooser() + { + var args = RecordEditView.BuildFollowLinkArgs(Request("publicationsEdit")); + + Assert.That(args.ToolName, Is.EqualTo("publicationsEdit")); + Assert.That(args.TargetGuid, Is.EqualTo(Guid.Empty), + "no target on the link — the legacy m_guidLink default"); + } + + [Test] + public void BuildFollowLinkArgs_WithATargetGuid_ParsesIt() + { + var guid = Guid.NewGuid(); + var args = RecordEditView.BuildFollowLinkArgs(Request("posEdit", guid.ToString())); + + Assert.That(args.ToolName, Is.EqualTo("posEdit")); + Assert.That(args.TargetGuid, Is.EqualTo(guid)); + } + + [Test] + public void BuildFollowLinkArgs_GarbageTarget_FallsBackToGuidEmpty() + { + var args = RecordEditView.BuildFollowLinkArgs(Request("publicationsEdit", "not-a-guid")); + + Assert.That(args.TargetGuid, Is.EqualTo(Guid.Empty)); + } + } +} diff --git a/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md b/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md index c047acacc6..e46597394b 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md +++ b/openspec/changes/lexical-edit-avalonia-migration/layout-import-coverage.md @@ -9,15 +9,15 @@ so they weight the vocabulary by how often real layouts use it. - Detail layouts imported: **136** (non-detail layouts skipped: 594) - Typed nodes produced: **1128** -- Element occurrence coverage: **75.5%** (6269 handled / 2031 unhandled) -- Attribute occurrence coverage: **55.5%** (14276 handled / 11435 unhandled) +- Element occurrence coverage: **77.8%** (6456 handled / 1844 unhandled) +- Attribute occurrence coverage: **56.6%** (14560 handled / 11151 unhandled) ## Import diagnostics by code | Code (severity) | Count | |---|---| | `unhandled-attribute (Info)` | 715 | -| `slice-content-dropped (Info)` | 497 | +| `slice-content-dropped (Info)` | 330 | | `dynamic-editor (Info)` | 122 | | `unresolved-part (Error)` | 63 | | `unhandled-attribute (Warning)` | 5 | @@ -76,8 +76,6 @@ so they weight the vocabulary by how often real layouts use it. | `para` | 131 | | `deParams` | 126 | | `configureMlString` | 99 | -| `chooserLink` | 94 | -| `chooserInfo` | 93 | | `forecolor` | 89 | | `span` | 65 | | `bold` | 52 | @@ -133,9 +131,6 @@ so they weight the vocabulary by how often real layouts use it. | `seq@inheritSeps` | 109 | | `deParams@ws` | 102 | | `configureMlString@field` | 99 | -| `chooserLink@label` | 94 | -| `chooserLink@tool` | 94 | -| `chooserLink@type` | 94 | | `layout@tagForWs` | 87 | | `forecolor@value` | 85 | | `string@class` | 77 | @@ -247,7 +242,6 @@ so they weight the vocabulary by how often real layouts use it. | `RecordChangeHandler@class` | 2 | | `RecordChangeHandler@listName` | 2 | | `chooserInfo@helpBrowser` | 2 | -| `chooserLink@target` | 2 | | `deParams@editable` | 2 | | `part@indent` | 2 | | `picture@height` | 2 | diff --git a/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md index fc4b9f51fa..b76569d31d 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md +++ b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md @@ -162,6 +162,30 @@ Status (wave 4, 2026-06-11): LANDED. rows light up with zero further plugin work. Choose/replace media file (vs play) rides the media lane (6.12) like the native player. +### Post-wave gap fixes (user-reported, 2026-06-11): LANDED + +- **Chooser jump links (gear flyouts):** the legacy chooser dialog's "Edit the … list" + LinkLabels (`ReallySimpleListChooser.InitializeExtras`/`AddLink`, kGotoLink → + `PostMessage("FollowLink", new FwLinkArgs(tool, m_guidLink))`) are recreated end to end: + `` imports onto the typed node + (`ViewNode.ChooserLinks`, canonical-JSON `chooserLinks` block — B7's schema reservation), + the composer projects the "goto" links (the only kind the lexeme-editor layouts use; all 95 + shipped links are goto) onto chooser AND reference-vector rows + (`LexicalEditRegionField.ChooserLinks`), the gear/+ flyouts render them below the options + (`RegionLinkChrome`), and the click rides a `RegionLinkRequest` host callback that + `RecordEditView.OnRegionLinkRequested` dispatches as the identical legacy jump (settle, then + mediator `FollowLink` with `FwLinkArgs(tool, Guid.Empty)` — no lexeme-editor chooserInfo + sets `flidTextParam`). chooserInfo's other facets (title/text/guicontrol FlatList) remain + the measured B7 remainder. +- **Lexeme Form gear:** the legacy Lexeme Form slice's button is its slice TREE-NODE MENU + (`MoForm-Detail-AsLexemeForm` binds `menu="mnuDataTree-LexemeForm"`: Show in Concordance / + Swap with Allomorph / Convert to Affix Process/Allomorph) — NOT a chooser launcher (the + morph-type chooser with the `MorphTypeSwapLogic` gate is the child Morph Type row, which + already has its gear). Recreated data-driven: any text row whose layout carries a `menu=` + binding draws the same hover-revealed `RegionChrome` gear, and clicking it raises the SAME + slice-menu `RegionMenuRequest` a label right-click raises — the host shows the identical + xCore menu. Nothing is hardcoded to "LexemeForm". + ### D5. Governance: the burn-down is enforced by tests, not intentions - The companion-strip designated set may only **shrink** (pinned: a test asserts its exact diff --git a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md index cd38561ae7..bdfb6a1e29 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md +++ b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md @@ -223,9 +223,18 @@ change's surfaces. possibility vectors compose as editable `ReferenceVector` rows (add/remove through the fenced session, duplicate/garbage/unknown keys rejected without opening it), covered by `FullEntryRegionReferenceChooserTests`. `BuildPossibilityOptions(flat:)` implements the - FlatList guicontrol semantics; REMAINING: import `chooserInfo`/`chooserLink` onto the typed - node and thread the flat/title/link specs to the composer call sites (the composer currently - passes `flat: false`). + FlatList guicontrol semantics. +- **Status (jump links landed, 2026-06-11):** `chooserLink` now imports onto the typed node + (`ViewNode.ChooserLinks`: type/label/tool/target, the legacy reader's exact vocabulary; + canonical JSON reserves the `chooserLinks` block, closing B7's share of the cross-cutting + schema deadline) and threads through the composer onto chooser/reference-vector rows + (`LexicalEditRegionField.ChooserLinks`, "goto" only — all 95 shipped links are goto); the + gear flyouts render the links and `RecordEditView` dispatches the legacy jump (mediator + `FollowLink`, `FwLinkArgs(tool, Guid.Empty)` exactly like + `ReallySimpleListChooser.HandleAnyJump`). REMAINING: import `chooserInfo`'s OTHER facets + (title/text/textparam/flidTextParam/guicontrol — still reported as `slice-content-dropped`) + and thread the flat/title specs to the composer call sites (the composer currently passes + `flat: false`). ### B8. TreeView-heavy views @@ -370,10 +379,11 @@ B2 (ghost), B3 (conditionals), and B7 (chooser metadata) share one deadline: the JSON schema must **reserve their representation before Layer-1 shipped-definition generation freezes** (`canonical-view-definition-design.md` §5 open question 4), or the generated files take an early breaking `formatVersion` bump. This is a design-ordering blocker for 9.2, ahead -of any runtime work. **Status (2026-06-11): B2 and B3 representations are now reserved and -shipping** — ghost metadata (`ghost`/`ghostWs`/`ghostClass`/`ghostLabel`/`ghostInitMethod`) -and the `condition` object both serialize/deserialize in `ViewDefinitionJsonSerializer` with -round-trip coverage; only B7's chooser-metadata block remains to reserve. +of any runtime work. **Status (2026-06-11): B2, B3, and B7's link block are now reserved and +shipping** — ghost metadata (`ghost`/`ghostWs`/`ghostClass`/`ghostLabel`/`ghostInitMethod`), +the `condition` object, and the `chooserLinks` array all serialize/deserialize in +`ViewDefinitionJsonSerializer` with round-trip coverage; chooserInfo's non-link facets +(title/text/guicontrol) are the only chooser metadata still to reserve. ## The gate, restated From 391cb6bc987ca8676bec45ad35f3bbbca303a393 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 12 Jun 2026 06:34:35 -0400 Subject: [PATCH 07/14] Updates skills, tests and lexical edit view --- .../skills/fieldworks-avalonia-ui/SKILL.md | 87 ++++++ .../fieldworks-localization-review/SKILL.md | 72 +++++ .../fieldworks-managed-netfx-review/SKILL.md | 57 ++++ .../SKILL.md | 67 ++++ .../SKILL.md | 82 +++++ .../fieldworks-ui-wiring-review/SKILL.md | 74 +++++ .../fieldworks-uia2-parity-testing/SKILL.md | 85 +++++ .../SKILL.md | 135 ++++++++ .../references/architecture-patterns.md | 256 +++++++++++++++ .../references/lessons-learned.md | 70 +++++ .../references/migration-checklist.md | 100 ++++++ .../references/parity-evidence.md | 108 +++++++ .../references/seam-catalog.md | 76 +++++ .../fieldworks.avalonia-expert.agent.md | 14 + .github/instructions/avalonia.instructions.md | 5 + .../skills/fieldworks-avalonia-ui/SKILL.md | 38 --- .../fieldworks-localization-review/SKILL.md | 27 -- .../fieldworks-managed-netfx-review/SKILL.md | 27 -- .../SKILL.md | 33 -- .../SKILL.md | 44 --- .../fieldworks-ui-wiring-review/SKILL.md | 29 -- .../fieldworks-uia2-parity-testing/SKILL.md | 38 --- .../SKILL.md | 40 --- Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 6 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 4 + .../FwAvaloniaTests/FwOptionPickerTests.cs | 230 ++++++++++++++ .../FwAvaloniaTests/HoverRevealTests.cs | 108 ++++--- .../FwAvaloniaTests/RegionEditingTests.cs | 207 ++++++------- .../FwAvaloniaTests/RegionMenuTests.cs | 30 ++ Src/Common/FwAvalonia/Poc/PocDensity.cs | 15 + .../FwAvalonia/Region/FwFieldControls.cs | 293 ++++++------------ .../FwAvalonia/Region/FwOptionPicker.cs | 232 ++++++++++++++ .../FwAvalonia/Region/RegionMenuFlyout.cs | 11 +- Src/xWorks/FullEntryRegionComposer.cs | 139 ++++++++- .../FullEntryRegionReferenceChooserTests.cs | 89 +++++- .../winforms-free-lexeme-editor.md | 61 ++-- .../xml-retirement-blockers.md | 22 +- 37 files changed, 2334 insertions(+), 677 deletions(-) create mode 100644 .claude/skills/fieldworks-avalonia-ui/SKILL.md create mode 100644 .claude/skills/fieldworks-localization-review/SKILL.md create mode 100644 .claude/skills/fieldworks-managed-netfx-review/SKILL.md create mode 100644 .claude/skills/fieldworks-migration-scope-review/SKILL.md create mode 100644 .claude/skills/fieldworks-semantic-render-parity/SKILL.md create mode 100644 .claude/skills/fieldworks-ui-wiring-review/SKILL.md create mode 100644 .claude/skills/fieldworks-uia2-parity-testing/SKILL.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/references/architecture-patterns.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/references/migration-checklist.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md create mode 100644 .claude/skills/fieldworks-winforms-to-avalonia-migration/references/seam-catalog.md delete mode 100644 .github/skills/fieldworks-avalonia-ui/SKILL.md delete mode 100644 .github/skills/fieldworks-localization-review/SKILL.md delete mode 100644 .github/skills/fieldworks-managed-netfx-review/SKILL.md delete mode 100644 .github/skills/fieldworks-migration-scope-review/SKILL.md delete mode 100644 .github/skills/fieldworks-semantic-render-parity/SKILL.md delete mode 100644 .github/skills/fieldworks-ui-wiring-review/SKILL.md delete mode 100644 .github/skills/fieldworks-uia2-parity-testing/SKILL.md delete mode 100644 .github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs create mode 100644 Src/Common/FwAvalonia/Region/FwOptionPicker.cs diff --git a/.claude/skills/fieldworks-avalonia-ui/SKILL.md b/.claude/skills/fieldworks-avalonia-ui/SKILL.md new file mode 100644 index 0000000000..976a4d3193 --- /dev/null +++ b/.claude/skills/fieldworks-avalonia-ui/SKILL.md @@ -0,0 +1,87 @@ +--- +name: fieldworks-avalonia-ui +description: "Build, review, or fix Avalonia UI code in FieldWorks: XAML, MVVM, view models, owned controls, headless tests, preview host, accessibility identity, and product-vs-preview wiring. Use for any change under Src/Common/FwAvalonia/, Src/Common/FwAvaloniaPreviewHost/, or Src/**/*.Avalonia/, and for net48/net8 Avalonia test changes — even if the request only mentions a control, a binding, a style, or a flaky UI test. For whole-surface migration planning use fieldworks-winforms-to-avalonia-migration first." +--- + +# FieldWorks Avalonia UI + +## Use This For + +- Avalonia XAML, view models, commands, lifetimes, dispatching, and + resource/style changes. +- New or changed projects under `Src/**/**/*.Avalonia/`, + `Src/Common/FwAvalonia/`, and `Src/Common/FwAvaloniaPreviewHost/`. +- Preview Host module registration, sample data providers, and UI + diagnostics (see `.github/instructions/avalonia.instructions.md` for + build/preview commands and project layout rules). +- UI host wiring that selects between Avalonia and legacy UI — apply + `fieldworks-ui-wiring-review` alongside this skill. + +## Start From the Established Patterns + +Do not design controls or seams from scratch. The migration hub skill +(`fieldworks-winforms-to-avalonia-migration`) documents the decided +architecture; its `references/architecture-patterns.md` covers owned +controls, writing-system text fields, dialogs/flyouts, validation, and +lifetime. Canonical code to imitate: + +- Owned field controls: `Src/Common/FwAvalonia/Region/FwFieldControls.cs`, + `FwOptionPicker.cs`, `RegionMenuFlyout.cs`, `HoverReveal.cs` +- Region view + focus memory: `LexicalEditRegionView.cs`, + `RegionFocusMemory.cs` +- Seams (scheduler, lifetime, clipboard, edit sessions): + `Src/Common/FwAvalonia/Seams/ISeams.cs` +- Headless test setup: `Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs`; + examples in `RegionEditingTests.cs`, `VisualParityAndDensityTests.cs` +- Density constants: `Src/Common/FwAvalonia/Poc/PocDensity.cs` + +## Required Checks + +- Use current Avalonia docs for uncertain APIs; do not guess dispatcher, + headless, automation, or binding behavior. +- Keep product UI strings localizable (`FwAvaloniaStrings.resx` or the + StringTable lane); prototype hardcoded strings must be called out as gaps. +- Stamp stable, nonlocalized `AutomationProperties.AutomationId` (derived + from IR `StableId` where applicable) and localized + `AutomationProperties.Name` on user-facing controls. +- UI logic stays in bindings/view models where practical; avoid + logic-heavy code-behind. +- Marshal to the UI thread through `IUiScheduler` (or Avalonia dispatcher + in non-region code); no hidden `Task.Run`, no sync-over-async. +- Keep preview data lightweight unless the change explicitly opts into + LCModel/project data; product-facing paths use real edit-session/domain + contracts — detached DTO-only models remain preview-only. +- Headless tests: simulate input on `Window`, flush with + `Dispatcher.UIThread.RunJobs()`, and capture visual regression frames + with Skia (`UseHeadlessDrawing=false` + `CaptureRenderedFrame()`). +- Evidence runs through `./build.ps1` and `./test.ps1` via the normal repo + graph, not branch-only lanes. + +## Review Red Flags + +- A Common project directly references a feature module without an + explicit architecture decision. +- Preview-only code launched from product UI without a feature gate. +- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar + instead of proving the real broadcast/wiring path. +- The active Avalonia path drives hidden legacy rendering/menu + infrastructure (see the hub skill's hard rules). +- Sleep-based or timing-sensitive UI tests. +- Claims of accessibility, localization, IME, or keyboard parity without + executable evidence (see the hub skill's + `references/parity-evidence.md` §"Evidence language"). + +## Handoff + +Report Avalonia docs consulted, tests run, remaining prototype gaps, +whether the change is product-facing or preview-only, and how the live +wiring path was validated for each affected host. For parity work, say +whether visual evidence is control-level headless capture or live desktop +capture, and which automation identities were assigned. + +## Keep This Skill Current + +When a control pattern, headless-test technique, or Avalonia API gotcha +proves out (or a pointer above goes stale), update this skill in the same +PR — and route durable architecture lessons through the protocol in +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-localization-review/SKILL.md b/.claude/skills/fieldworks-localization-review/SKILL.md new file mode 100644 index 0000000000..b735dd43e5 --- /dev/null +++ b/.claude/skills/fieldworks-localization-review/SKILL.md @@ -0,0 +1,72 @@ +--- +name: fieldworks-localization-review +description: "Review or change FieldWorks user-facing strings: .resx resources, localization keys, the StringTable lane for field labels, Crowdin-facing assets, and localization-sensitive automation metadata. Use whenever a change adds or edits any user-visible text in WinForms or Avalonia, adds a new UI project, touches resource files, or claims localization parity — even for a single new label or error message." +--- + +# FieldWorks Localization Review + +## Use This For + +- Product-facing text in WinForms, Avalonia, settings UI, dialogs, + validation messages, fallback or unsupported-surface text, and promoted + preview paths. +- `.resx` additions or changes, localization key flow, and Crowdin-sensitive + resource updates. +- Automation metadata where `Name`, tooltip, or label is localized but + stable `AutomationId` must remain nonlocalized. + +## The Two Lanes (Avalonia surfaces) + +1. **Field labels** come from layout data and resolve through the legacy + StringTable lane (`XmlUtils.GetLocalizedAttributeValue`, + `strings-{locale}.xml`) at render time. The view-definition IR carries a + `LocalizationKey` per node; never bake English label text into the IR or + region model. +2. **Product messages** (Save, Cancel, validation errors, unsupported-row + text) live in `Src/Common/FwAvalonia/FwAvaloniaStrings.resx` with + translator comments; accessor class `FwAvaloniaStrings.cs`; key coverage + locked by tests in + `Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs`. + +## Required Checks + +- Product-facing user-visible strings live in `.resx` or the established + localization mechanism; preview-only hardcoded text stays clearly + preview-only. +- New UI mode labels, fallback or unsupported messages, validation errors, + and diagnostics are localized before a product path is exposed. +- Stable `AutomationId` and other selectors remain nonlocalized; localized + names, tooltips, and labels may vary by locale. +- Resource keys and files align with existing Crowdin and repo conventions. +- New SDK-style csprojs declare `` explicitly — the Crowdin + satellite-assembly build + (`Build/Src/FwBuildTasks/Localization/ProjectLocalizer.cs`) fails + without it. +- If localization parity is claimed, tests or evidence cover the localized + path and confirm selectors do not depend on localized text. English on + the Avalonia surface where legacy shows translations is a parity + failure, not cosmetics. + +## Review Red Flags + +- Hardcoded English text in product C#, XAML, or product-facing + preview-promotion paths. +- Field labels rendered raw from the IR without StringTable resolution. +- Tests or automation selectors depending on localized labels when stable + IDs exist or are required. +- A product route reusing preview-only placeholder text. +- Localization claims without resource updates or without identifying + remaining hardcoded strings. + +## Handoff + +List the resource files or keys touched, remaining hardcoded product +strings, automation identity strategy, and whether localized behavior has +executable evidence or is still pending. + +## Keep This Skill Current + +When a new localization lane, Crowdin constraint, or resource convention +appears (or a gap like the `` one is found), record it here +in the same PR; route durable lessons through +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-managed-netfx-review/SKILL.md b/.claude/skills/fieldworks-managed-netfx-review/SKILL.md new file mode 100644 index 0000000000..3243a5dab5 --- /dev/null +++ b/.claude/skills/fieldworks-managed-netfx-review/SKILL.md @@ -0,0 +1,57 @@ +--- +name: fieldworks-managed-netfx-review +description: "Review or change FieldWorks managed C# code that crosses the .NET Framework 4.8 / C# 7.3 vs SDK-style net8 boundary: project files, language-feature compatibility, test discovery across both runtimes, UI-thread marshaling, and deterministic disposal. Use whenever a change touches a .csproj, adds C# to an unfamiliar project, moves code between net48 and net8 projects, or changes test runners — even if the compile passes locally." +--- + +# FieldWorks Managed NetFx Review + +## Compatibility Split + +- Legacy product code is .NET Framework 4.8 and C# 7.3 unless a project + explicitly targets modern .NET. The compiler will not always save you: + check the project's `LangVersion`/target before writing modern syntax. +- New Avalonia modules may target `net8.0-windows`; do not leak C# 8+ + syntax or net8-only APIs into net48 projects. Note that + `Src/Common/FwAvalonia/` itself is consumed from net48 hosts — verify a + project's actual target rather than assuming Avalonia ⇒ net8. +- Legacy `.csproj` files require explicit source inclusion; SDK-style + projects glob by default. A file added on disk is not necessarily in the + build. +- SDK-style projects need an explicit `` for the Crowdin + satellite-assembly build (see `fieldworks-localization-review`). + +## Required Checks + +- User-visible strings use `.resx` patterns where product-facing. +- UI and async code marshals to the correct UI thread (via `IUiScheduler` + in region code) and does not use sync-over-async. +- Disposable WinForms/GDI/LCModel/test resources are owned and disposed + deterministically; region code follows the `IRegionLifetime` rules + (idempotent disposal, late-callback suppression, event unsubscribe). +- Test discovery changes are validated across both net48 and net8 test + assemblies. +- Use repo scripts for evidence: `./build.ps1` and `./test.ps1` — never + bare `dotnet build` conclusions. + +## Review Red Flags + +- Nullable annotations, records, file-scoped namespaces, switch + expressions, or `using var` in net48/C# 7.3 projects. +- Broad project/test-runner changes justified only by one local test + passing. +- Hardcoded Debug paths or absolute repo assumptions in tests. +- Skipped tests used as evidence of covered behavior. +- A new project added to disk but missing from `FieldWorks.proj` + (traversal build) or `FieldWorks.sln` (IDE discovery). + +## Handoff + +Report target frameworks touched, project-file implications, test +commands/results, and any remaining compatibility risks. + +## Keep This Skill Current + +When a new cross-target pitfall, project-file gotcha, or runtime +difference bites a migration, add it here in the same PR; route durable +lessons through +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-migration-scope-review/SKILL.md b/.claude/skills/fieldworks-migration-scope-review/SKILL.md new file mode 100644 index 0000000000..aac60f87d4 --- /dev/null +++ b/.claude/skills/fieldworks-migration-scope-review/SKILL.md @@ -0,0 +1,67 @@ +--- +name: fieldworks-migration-scope-review +description: "Review the scope and evidence claims of large FieldWorks migration PRs, OpenSpec changes, and foundational branches. Use when sizing or splitting a branch, judging draft-PR readiness, verifying that checked tasks match their evidence, or whenever a reviewer or author asks whether a migration PR is too big, mixed, or trustworthy." +--- + +# FieldWorks Migration Scope Review + +## Review Posture + +Treat foundational migration PRs as architecture and evidence packages. +The main question is whether reviewers can trust the scope, claims, and +validation boundary. + +## Required Checks + +- Scope review is branch-relative: compare `main..HEAD` or the merge-base + diff, not calendar-time commit lists. Same-day commits already on `main` + are not branch scope. +- Compare PR title/body/tasks against the actual diff. +- Classify files as plan/spec, characterization test, infrastructure, + prototype, product behavior, or unrelated change. +- When product or global UI wiring appears, trace preview-vs-product + routing and host/listener wiring separately from plan/test changes + (apply `fieldworks-ui-wiring-review`). +- Verify checked tasks match evidence language; downgrade claims when + evidence says substitute, placeholder, skipped, future, or partial — + the taxonomy is defined in + `fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md` + §"Evidence language". +- Confirm validation gates are explicit: OpenSpec validation + (`openspec validate --strict`), targeted tests, normal + `./build.ps1` and `./test.ps1` coverage for Avalonia, and + `CI: Full local check` when ready. + +## Split Triggers + +- Product-visible behavior appears in a planning/test PR. +- Branch-only diff mixes product-visible wiring with planning/test/docs/ + prototype work. +- Common infrastructure directly depends on the first feature module + without an explicit decision. +- Test-runner/build graph changes are mixed with UI migration work. +- Unrelated behavior changes require their own review context. + +## Review Red Flags + +- A draft PR so broad that each reviewer must reverse-engineer intent. +- Scope complaints based on "commits made today" instead of the + branch-only diff against `main`. +- Evidence stale after rebase or differing from visible CI state. +- A prototype wired as if it were a product feature. +- Skill/playbook updates from the migration retrospective missing from a + PR that completed a migration phase (see the hub skill's workflow + step 10) — institutional knowledge is part of the deliverable. + +## Handoff + +Lead with blockers, then list what to remove, split, reword, or validate +before review. Call out false scope signals separately from real +branch-only scope problems. + +## Keep This Skill Current + +When a new split trigger, evidence-language term, or scope failure mode +shows up in a real review, add it here in the same PR; route durable +lessons through +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-semantic-render-parity/SKILL.md b/.claude/skills/fieldworks-semantic-render-parity/SKILL.md new file mode 100644 index 0000000000..5f40e689d3 --- /dev/null +++ b/.claude/skills/fieldworks-semantic-render-parity/SKILL.md @@ -0,0 +1,82 @@ +--- +name: fieldworks-semantic-render-parity +description: "Capture or review FieldWorks parity evidence: semantic snapshots, render/visual baselines, layout parity, failure artifacts, XML view definitions, and the Avalonia presentation IR. Use whenever a task creates or evaluates snapshot tests, screenshot baselines, view-definition compilation output, or any claim that an Avalonia surface matches its WinForms predecessor." +--- + +# FieldWorks Semantic Render Parity + +Shared definitions (Path 3 bundle, evidence lanes, artifact naming) live in +`fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md`. +This skill covers how to build and review the snapshots themselves. + +## Snapshot Discipline + +Semantic snapshots preserve behaviorally meaningful identity and omit +incidental layout noise. The snapshot is the anchor artifact of a parity +bundle: when visual evidence diverges, the snapshot explains whether the +cause is the XML import, slice filtering, editor registry, or rendering. + +## Include + +- Stable node ID and source layout/part identity. +- Which route produced the artifact (`Avalonia`, legacy fallback, or + blocked state) when a scenario can run through multiple hosts. +- Object/class binding, field/flid binding, editor kind, writing-system + metadata, visibility, ghost state, expansion, focus order, localization + key, and accessibility identity. +- Unsupported construct diagnostics with enough path context to fix the + source layout. + +## Exclude Or Normalize + +- Pixel bounds, transient generated names, timestamps, machine paths, + culture-dependent ordering, and realized-control counts unless the test + explicitly owns them. + +## Canonical Examples + +- IR model and snapshot projection: + `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs` +- Snapshot/parity tests: + `Src/Common/FwAvalonia/FwAvaloniaTests/RegionViewingParityTests.cs`, + `ViewDefinitionTests.cs`, `BrowseAndCanonicalJsonTests.cs`, + `Path3BundleTests.cs` +- Import coverage tracking: `LayoutImportCoverageTests.cs` and + `Src/Common/FwAvalonia/ViewDefinition/LayoutImportCoverage.cs` +- Visual/density evidence: `VisualParityAndDensityTests.cs` + +## Render Evidence + +- Pixel/render tests need deterministic fixtures, clear thresholds, and + failure artifacts reviewers can inspect (classified failure summary, not + a raw diff image). +- A semantic snapshot is not a substitute for visual/render parity when + typography, density, wrapping, or native rendering seams are under + review — and vice versa. One lane per axis; see parity-evidence.md §2. +- Control-level Avalonia visual evidence may come from Avalonia.Headless + rendered frames when the scenario is explicitly control-scoped; desktop + workflow/accessibility claims still need live-window evidence. + +## Review Red Flags + +- A preview-only or lossy route presented as if it proved product parity. +- Placeholder metadata presented as real binding or writing-system parity. +- Snapshot tests updating large JSON blobs without a small behavioral + explanation of what changed and why. +- Cache invalidation tests that depend on sleeps or filesystem timestamp + luck. +- A new layout construct silently dropped by the importer instead of + producing a diagnostic node and a coverage-tracking entry. + +## Handoff + +State whether evidence is semantic, visual, accessibility/workflow, or +performance parity, and identify remaining unproven axes. When a Path 3 +bundle is used, name each artifact and which lane it proves. + +## Keep This Skill Current + +When snapshot fields, normalization rules, or fixture patterns change, or +a new artifact type joins the bundle, update this skill and +parity-evidence.md together in the same PR; record durable lessons via +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-ui-wiring-review/SKILL.md b/.claude/skills/fieldworks-ui-wiring-review/SKILL.md new file mode 100644 index 0000000000..513e51a0ff --- /dev/null +++ b/.claude/skills/fieldworks-ui-wiring-review/SKILL.md @@ -0,0 +1,74 @@ +--- +name: fieldworks-ui-wiring-review +description: "Review or change FieldWorks UI wiring — app-setting and PropertyTable routing, mediator notifications, current-content switching, host replacement, preview-vs-product boundaries, and the global legacy-vs-Avalonia UI selection. Use whenever a change touches which UI host is active, how a setting reaches a screen, RecordEditView/currentContentControl routing, save/PrepareToGoAway paths, or fallback behavior — even if the diff looks like a one-line settings change." +--- + +# FieldWorks UI Wiring Review + +## Use This For + +- Global or screen-level UI mode selection. +- `PropertyTable`, app-setting, mediator, or listener changes that affect + which UI host is active. +- `RecordEditView`, `currentContentControl`, host replacement, save or + `PrepareToGoAway()` routing, focus or command target routing, and + preview-to-product promotion work. + +## Canonical Wiring + +The decided routing model is explicit per-host behavior — supported +Avalonia, explicit legacy fallback, or blocked — never silent fallback: + +- Surface selection: `Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs`, + `LexicalEditSurfaceResolver.cs` (behavior enum + routing logic) +- Approved legacy adapters: `Src/Common/FwAvalonia/Seams/ActiveHostContract.cs` +- Contract tests to imitate: + `Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs`, + `Src/Common/FwAvalonia/FwAvaloniaTests/SurfaceAndHostContractTests.cs`, + `LexicalEditSurfaceResolverTests.cs` + +## Required Checks + +- Review scope against the branch-only diff (`main..HEAD`) and list every + host or consumer affected. +- Trace the full wiring path end to end: setting source, persisted state, + `PropertyTable` key, mediator or property broadcast, listener + registration, host reload path, focus or command target routing, save or + `PrepareToGoAway()` path, and fallback or blocked state. +- For global switches, verify each current consumer has an explicit + contract: supported Avalonia surface, explicit legacy fallback, or + resource-backed unsupported state. +- The active Avalonia route must not instantiate or drive hidden legacy + rendering or menu infrastructure except through `ActiveHostContract` + approved adapters — and prove the negative with a contract test, not by + inspection alone. +- Product wiring and preview wiring are reviewed separately; preview DTOs, + preview hosts, and spike-only semantics do not satisfy product routing. +- Validation uses the normal repo build and test path (`./build.ps1`, + `./test.ps1`) plus host-specific tests when wiring changes. + +## Review Red Flags + +- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar + handlers instead of driving the real setting and broadcast path. +- A preview-only mapper or detached DTO model sits on a product-facing + route. +- Hidden legacy `DataTree`, menu handler, or renderer is still initialized + and driven while Avalonia is the active host. +- A global setting changes unrelated screens without a manifest or + explicit fallback story. +- Build or test evidence relies mainly on branch-only optional lanes or ad + hoc commands. + +## Handoff + +Report the setting source, listeners, affected hosts, per-host fallback +state, executable proof of the live wiring path, and any remaining hidden +legacy dependencies. + +## Keep This Skill Current + +When a new host type, routing pattern, or wiring failure mode appears in a +migration, add it here (and a red flag if it is a review smell) in the same +PR. Durable lessons also go through +`fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-uia2-parity-testing/SKILL.md b/.claude/skills/fieldworks-uia2-parity-testing/SKILL.md new file mode 100644 index 0000000000..4d2e19b139 --- /dev/null +++ b/.claude/skills/fieldworks-uia2-parity-testing/SKILL.md @@ -0,0 +1,85 @@ +--- +name: fieldworks-uia2-parity-testing +description: "Design or review FieldWorks UI automation and accessibility tests: UIA2, FlaUI, Appium, WinAppDriver, Avalonia.Headless, keyboard, focus, IME, and automation-id strategy. Use whenever a task adds, changes, or evaluates automated UI tests or accessibility/workflow parity claims for WinForms or Avalonia surfaces — including deciding whether a test belongs in the headless or desktop lane." +--- + +# FieldWorks UIA2 Parity Testing + +## Lane Separation + +- Avalonia.Headless is for fast in-process control, layout, view-model, + binding, and input tests. +- UIA2/FlaUI/Appium/WinAppDriver tests require realized desktop windows and + validate native accessibility trees, focus, invoke patterns, and product + integration. +- Do not call a headless smoke test a UIA2 baseline. + +## Role in the Parity Bundle + +In a Path 3 parity bundle (defined in +`fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md`), +desktop automation contributes the workflow/accessibility lane only: +launcher/chooser reachability, focus movement and return, invoke/cancel/ +accept paths, native automation tree identity, and shell-level keyboard +behavior. It does not replace semantic snapshots or visual/render evidence; +report it alongside those artifacts for the same scenario id. + +## Canonical Examples + +- Headless app/test setup: + `Src/Common/FwAvalonia/FwAvaloniaTests/TestAppBuilder.cs`; input and + focus patterns in `RegionEditingTests.cs`, `RegionFocusMemoryTests.cs` +- Realized-window UIA smoke on the legacy product path: + `Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs` +- Preview-host UIA: + `Src/Common/FwAvalonia/FwAvaloniaPreviewHostTests/PreviewHostUiaTests.cs` +- Automation-id locking: `Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs` + +## Automation Identity + +Derive AutomationIds from the IR `StableId` (`{StableId}`, +`{StableId}.Label`, `{StableId}.{WsAbbrev}`), defined as code constants — +never resource keys, never localized text. Localized names/tooltips go on +`AutomationProperties.Name`. Owned controls need custom automation peers +when stock peers do not expose the required patterns. + +## Required Evidence + +- Stable automation IDs or accessible names for controls under test. +- Explicit coverage of focus movement, invoke/click path, popup/chooser + reachability, keyboard shortcuts, and failure artifacts. +- When UI mode or host wiring changes, desktop automation must cover the + real switch-driven host refresh or fallback behavior on realized windows; + manual handler calls or headless-only assertions do not prove product + wiring. +- Clear CI lane: headless can run broadly; desktop automation needs an + interactive Windows desktop or a configured automation host. + +## Review Red Flags + +- "Runs in the background" used for UIA2/Appium without explaining the + required desktop/session. +- Manual `OnPropertyChanged(...)` or similar handler invocation presented + as proof of live UI-mode wiring. +- Tests assert implementation internals instead of user-observable + accessibility behavior. +- Automation selectors rely on localized labels when stable IDs are + available or required. +- IME coverage claimed without a real text editor/control surface and + input-method evidence. (IME composition/commit is a known open gap for + rich-text scope — do not let a checkbox claim it implicitly.) +- Sleep-based waits instead of event-driven synchronization. + +## Handoff + +Classify each test as headless, native desktop automation, or smoke +substitute, and state what parity claim it can and cannot support. For +bundle work, say which workflow/accessibility assertions the desktop lane +proved, whether switch wiring/fallback was exercised on a realized window, +and which claims still need another lane. + +## Keep This Skill Current + +When a new automation pattern, peer implementation, CI-lane constraint, or +flakiness fix proves out, add it here in the same PR; route durable lessons +through `fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md`. diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md new file mode 100644 index 0000000000..23f5d896dc --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md @@ -0,0 +1,135 @@ +--- +name: fieldworks-winforms-to-avalonia-migration +description: "End-to-end playbook for migrating any FieldWorks WinForms surface (DataTree slices, XMLViews browse/table, dialogs, choosers, launchers, shell panes) to Avalonia using the established region/seam architecture. Use whenever planning, implementing, or reviewing WinForms-to-Avalonia work — including seam extraction, region composition, owned controls, plugin editors, parity evidence, or retiring legacy surfaces — even if the request only says port, modernize, replace WinForms, or new Avalonia view. Also use after finishing a migration to run the retrospective step that folds new lessons back into these skills." +--- + +# FieldWorks WinForms To Avalonia Migration + +This is the hub skill for the migration program. It tells you what +architecture already exists (do not reinvent it), what order to work in, +which companion skill to apply at each step, and how to keep this skill +set current as more surfaces are migrated. + +## Core Rule + +Migrate by proving behavior first, extracting seams second, and introducing +Avalonia controls only after legacy behavior has executable parity evidence. +A region is not "migrated" until it passes the symbol audit, parity gates, +and has zero runtime dependency on native Views/DataTree infrastructure — +otherwise you have only wrapped the old system. + +## Established Architecture — Reuse, Don't Reinvent + +Past migrations already decided the paradigms below. Before writing any new +abstraction, read `references/architecture-patterns.md` (table of contents at +top) for the decision, the why, and the gotchas. Quick map: + +| Pattern | Canonical code | Details | +| --- | --- | --- | +| Typed view-definition IR compiled from XML layouts | `Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs`, `XmlLayoutImporter.cs`, `ViewDefinitionCompiler.cs` | architecture-patterns.md §1 | +| Region model + composer (boundary sits *above* DataTree) | `Src/xWorks/FullEntryRegionComposer.cs`, `Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs`, `LexicalEditRegionMapper.cs` | §2 | +| Explicit surface selection per host (`HostUiBehavior`) | `Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs` | §3 | +| Owned dense controls, not stock property grids | `Src/Common/FwAvalonia/Region/FwFieldControls.cs`, `FwOptionPicker.cs`, `RegionMenuFlyout.cs` | §4 | +| Plugin registry for custom/legacy slice classes | `Src/xWorks/RegionEditorPlugins.cs`, `Src/xWorks/ChorusNotesPlugin.cs` | §5 | +| Seam contracts (edit session, undo, validation, scheduler, lifetime, refresh) | `Src/Common/FwAvalonia/Seams/ISeams.cs` | `references/seam-catalog.md` | +| Writing-system-aware text fields (font, RTL, keyboard per WS) | `Src/Common/FwAvalonia/Region/FwFieldControls.cs` (`FwMultiWsTextField`) | architecture-patterns.md §6 | +| Dialog ownership across the WinForms/Avalonia boundary | `openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md` | §7 | + +## Workflow + +Work through the phases in order. Copy +`references/migration-checklist.md` into your task notes and check items +off — it is the per-region definition of done. + +1. **Inventory and scope.** Identify the legacy surface, its entry points, + layouts/parts, custom slice classes, dialogs, and command wiring. + Produce a coverage map (surface × behavior × test status). Apply + `fieldworks-migration-scope-review` when sizing the PR/branch. +2. **Characterize before refactor.** Lock current behavior in executable + tests (semantic baselines, timing baselines, UIA smoke) *before* + extracting anything. Gates: every behavior is tested, consciously + deferred with an owner, or blocked by a named seam. Examples: + `Src/xWorks/xWorksTests/WinFormsUiaSmokeTests.cs`, + `Src/Common/Controls/DetailControls/DetailControlsTests/`. +3. **Extract seams.** Reuse the existing contracts in + `Src/Common/FwAvalonia/Seams/ISeams.cs`; only add a new seam when + `references/seam-catalog.md` has no fit, and record why there. +4. **Select controls.** Default to the owned-control decisions in + architecture-patterns.md §4. Re-evaluate only when a pivot trigger in + seam-catalog.md §"Pivot triggers" has fired. +5. **Compose the region.** Walk the compiled IR in a composer, project into + a region model, route custom classes through the plugin registry, and + render unclaimed classes as explicit "unsupported" rows — never silent + fallback. Apply `fieldworks-avalonia-ui` for the control work. +6. **Wire the host.** Explicit per-host contract: supported Avalonia, + explicit legacy fallback, or blocked. Apply `fieldworks-ui-wiring-review`. +7. **Prove parity.** Build the evidence bundle defined in + `references/parity-evidence.md` (semantic + visual + workflow lanes). + Apply `fieldworks-semantic-render-parity` and + `fieldworks-uia2-parity-testing`. +8. **Localize.** Apply `fieldworks-localization-review`; field labels go + through the StringTable lane, product messages through + `FwAvaloniaStrings.resx`. +9. **Retire and gate.** Run the symbol audit + (`Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs`), + active-host contract tests + (`Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs`), + and the normal repo gates (`./build.ps1`, `./test.ps1`). +10. **Retrospective.** Update these skills — see "Keep this skill set + current" below. This step is part of the migration, not optional polish. + +## Hard Rules + +- Active Avalonia hosts must not instantiate or drive hidden legacy + `DataTree`, `Slice`, `RootSite`, menu, or renderer infrastructure except + through approved baseline adapters + (`Src/Common/FwAvalonia/Seams/ActiveHostContract.cs`). +- Migrated-region production code must stay free of the forbidden symbols + listed in parity-evidence.md §"Forbidden symbols" (enforced by + `EngineIsolationAuditTests.cs`). +- Evidence comes from the normal repo path: `./build.ps1` and `./test.ps1`. + Branch-only lanes or ad hoc commands are not integration evidence. +- One global undo/redo stack (LCModel action handler). Never a parallel + Avalonia-only history for committed state. +- Avalonia modal windows are not supported during coexistence; anything + modal uses a WinForms dialog with the host form as owner (see + architecture-patterns.md §7). +- Performance budgets are measured against legacy baselines, not estimated + (parity-evidence.md §"Performance budgets"). + +## Review Red Flags + +- Tests manually invoke `OnPropertyChanged`, `ShowRecord`, or similar + handlers to simulate runtime wiring instead of driving the real path. +- Active Avalonia routing depends on a lossy DTO mapper or preview-only + code without an explicit product contract. +- Task checkboxes claim parity while evidence says substitute, placeholder, + skipped, or future work (see parity-evidence.md §"Evidence language"). +- A custom slice class silently renders wrong instead of producing an + explicit unsupported row. +- A PR mixes plans, tests, infrastructure, product wiring, and unrelated + changes — apply `fieldworks-migration-scope-review`. + +## Handoff + +State what is legacy baseline, what is extracted seam, what is Avalonia +product surface, what each affected host does under the global switch, what +remains outside parity, and what you changed in this skill set during the +retrospective. + +## Keep This Skill Set Current + +These skills are the institutional memory of the migration. Every completed +migration teaches something; if it stays in your head or in a PR thread it +is lost. The retrospective step (workflow step 10) is how the skills stay +ahead of the codebase instead of trailing it: + +1. Read `references/lessons-learned.md` and follow its update protocol — + it maps each kind of discovery (new pattern, new gotcha, fired pivot + trigger, new canonical example, stale pointer) to the exact file and + section to update. +2. Make the skill edits in the same PR as the migration, so reviewers see + the lesson next to the evidence that produced it. +3. If a file pointer in any of these skills is stale (file moved, openspec + change archived), fix the pointer immediately — do not work around it + silently. diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/architecture-patterns.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/architecture-patterns.md new file mode 100644 index 0000000000..81bcad833b --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/architecture-patterns.md @@ -0,0 +1,256 @@ +# Established Migration Architecture Patterns + +Decisions already made by the lexical-edit migration. Each section gives the +decision, why it was made, the canonical code, and gotchas. Provenance for +every decision lives in `openspec/changes/lexical-edit-avalonia-migration/` +(if that change has been archived, look under `openspec/changes/archive/`). + +Contents: + +1. Typed view-definition IR (the long-term contract) +2. Region model + composer (boundary above DataTree) +3. Explicit surface selection per host +4. Owned dense controls (control-selection decisions) +5. Plugin registry for custom slice classes +6. Writing-system behavior (font, RTL, keyboard, multi-WS) +7. Dialog ownership and modality across the interop boundary +8. Undo/redo, edit sessions, and refresh +9. Validation +10. Custom fields and ghost rows +11. Localization lanes +12. Density and performance + +## 1. Typed view-definition IR (the long-term contract) + +**Decision.** XML Parts/Layouts are compiled into a typed +`ViewDefinitionModel` (one `ViewNode` per field carrying StableId, editor +kind, writing system, visibility, expansion, custom-field placeholder +metadata, accessibility id, and localization key). Avalonia consumes the IR, +never raw XML. XML is an import format during transition, not the runtime +abstraction; the retirement path is deterministic JSON +(`ViewDefinitionJsonSerializer.cs`) plus customer override patches. + +**Why.** Keeps customer layout customizations alive, creates a clean +DI/test boundary, enables off-thread compilation and snapshot-based parity +tests, and gives XML a retirement path. + +**Canonical code.** `Src/Common/FwAvalonia/ViewDefinition/` — +`ViewDefinitionModel.cs`, `XmlLayoutImporter.cs`, `ViewDefinitionCompiler.cs` +(caches by immutable source-snapshot fingerprint), +`ViewDefinitionJsonSerializer.cs`, `LayoutImportCoverage.cs`. +Tests: `Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionTests.cs`, +`LayoutImportCoverageTests.cs`, `BrowseAndCanonicalJsonTests.cs`. + +**Gotchas.** Compilation must stay deterministic (same source snapshot → +identical IR) because parity snapshots key off it. Track element/attribute +import coverage explicitly; an unimported construct must surface as a +diagnostic node, not vanish. + +## 2. Region model + composer (boundary above DataTree) + +**Decision.** The migration boundary sits at the region-model layer above +`DataTree`, not inside it. A composer walks the compiled IR the way legacy +DataTree walks layouts and emits a region model (renderable fields keyed by +IR StableId) plus an edit context. DataTree internals are never extracted — +they are deleted at the end of coexistence, so extracting them is throwaway +work. + +**Canonical code.** `Src/xWorks/FullEntryRegionComposer.cs` (walks IR, +emits `ComposedEntryRegion`), +`Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs`, +`LexicalEditRegionMapper.cs`, `IRegionEditContext.cs`, +`LexicalEditRegionView.cs`. +Tests: `RegionModelTests.cs`, `RegionEditingTests.cs`, +`RegionViewingParityTests.cs` in `Src/Common/FwAvalonia/FwAvaloniaTests/`. + +**Gotchas.** The region model is presentation data, not LCModel objects — +it is projected from `IRegionValueProvider` style seams so it can be built +and tested off-thread without WinForms or a real project. + +## 3. Explicit surface selection per host + +**Decision.** Every host that can show legacy or Avalonia UI resolves an +explicit `HostUiBehavior`: supported Avalonia, explicit legacy fallback, or +blocked. No silent fallback. The active host must never drive hidden legacy +DataTree/menu/renderer infrastructure except through approved baseline +adapters. + +**Canonical code.** +`Src/Common/FwAvalonia/LexicalEditSurfaceSelectionService.cs`, +`LexicalEditSurfaceResolver.cs`, `LexicalEditSurfaceFactory.cs`, +`Src/Common/FwAvalonia/Seams/ActiveHostContract.cs` (approved-adapter +whitelist). +Tests: `LexicalEditSurfaceResolverTests.cs`, +`SurfaceAndHostContractTests.cs`, +`Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs`. + +**Gotchas.** "Convenience" calls into legacy internals while Avalonia is +visible (for example, to harvest metadata) defeat the boundary — the +contract tests exist to catch exactly that. + +## 4. Owned dense controls (control-selection decisions) + +**Decision.** Build FieldWorks-owned row/field controls on top of stock +virtualization primitives instead of adopting a stock property grid or +TreeDataGrid: + +- Detail view (DataTree replacement): owned slice list over + `ListBox`/`VirtualizingStackPanel` — flatten in the model, virtualize + with stock primitives, own the row. +- Browse/table (XMLViews replacement): owned virtualized table — flattened + row list + shared column header + owned cell layout + (`Src/Common/FwAvalonia/Region/LexicalBrowseView.cs`). +- Bounded popup trees (≤500 items): stock `TreeView` with an explicit + item-count ceiling, validated at 100%/150% DPI. +- Unbounded trees: the owned flattened virtualized list with + expander/indent row chrome. + +**Why.** Stock grids fit poorly with nested senses, multi-WS alternatives, +custom choosers, dense rows, and FieldWorks keyboard behavior; owning the +row keeps the UI framework out of domain semantics. TreeDataGrid was +rejected on licensing and editing/automation gaps (see pivot triggers in +`seam-catalog.md` — revisit if those facts change). + +**Canonical code.** `Src/Common/FwAvalonia/Region/FwFieldControls.cs` +(`RegionFieldKind`: Text, Chooser, Boolean, Image, Command, +ReferenceVector, Custom), `FwOptionPicker.cs`, `RegionMenuFlyout.cs`, +`HoverReveal.cs`, `RegionFocusMemory.cs`. + +## 5. Plugin registry for custom slice classes + +**Decision.** Legacy layouts reference custom slice classes by name (for +example `SIL.FieldWorks.XWorks.LexEd.MessageSlice`). A plugin registry maps +those same class identities to factories that build Avalonia controls. +Resolution order: plugin → companion-strip WinForms coexistence → +explicit "unsupported" row. Never silent mis-render. Keying by legacy class +identity means zero layout edits and measurable burn-down (census vs. +registry coverage). + +**Canonical code.** `Src/xWorks/RegionEditorPlugins.cs`, +`Src/xWorks/ChorusNotesPlugin.cs`, registry contracts in +`Src/Common/FwAvalonia/Region/LexicalEditRegionModel.cs`. +Tests: `Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs`, +`LexemeEditorBurnDownTests.cs`, `MessagesCompanionLaneTests.cs`. + +**When migrating a new surface:** census its custom slice classes first, +check the registry for existing plugins, and add plugins (with tests) for +the rest. Add each new plugin to the burn-down tracking. + +## 6. Writing-system behavior (font, RTL, keyboard, multi-WS) + +**Decision.** Every text field renders per-writing-system rows: WS +abbreviation gutter + value box, with font family/size, flow direction +(RTL/LTR), and keyboard activation projected from LCModel WS metadata. +Keyboard switches on focus (legacy `EditingHelper.SetKeyboardForWs` +behavior). OpenType features ship via HarfBuzz; native Graphite is never +loaded on the Avalonia path — Graphite-dependent writing systems are +classified and warned, not blocked. + +**Canonical code.** `FwMultiWsTextField` in +`Src/Common/FwAvalonia/Region/FwFieldControls.cs`; `RegionWsValue` +(WsAbbrev, FontFamily, FontSize, RightToLeft, WsTag) in +`LexicalEditRegionModel.cs`. +Tests: `TreeSpikeAndRtlTests.cs`, `VisualParityAndDensityTests.cs`. + +**Gotchas.** Never assume one font, one direction, or one script per +field. Test mixed-script content at 100% and 150% DPI with real fonts. + +## 7. Dialog ownership and modality across the interop boundary + +**Decision.** During coexistence there is one UI thread and one message +loop. Rules (provenance: +`openspec/changes/lexical-edit-avalonia-migration/dialog-ownership.md`): + +- Anything modal is a WinForms dialog, owned by the hosting WinForms + top-level form (`Control.FindForm()` of the host) — never `null`, never + an Avalonia handle. Avalonia modal windows are not used (unsupported on + the 11.x coexistence path). +- Record the focused Avalonia control before `ShowDialog` and restore focus + explicitly after close. +- Use Avalonia flyouts inside the hosted surface, not free popup windows + (mixed-DPI positioning). +- No cross-boundary Tab order between WinForms siblings and the Avalonia + surface; own focus inside the surface. +- No WinForms modeless tool windows owned by an Avalonia surface. + +## 8. Undo/redo, edit sessions, and refresh + +**Decision.** Edits ride a fenced `IEditSession` +(Active → Saved/Canceled → Disposed) wrapping an LCModel undo task — one +undoable action per save regardless of field count. Transient text undo +stays local to the focused TextBox. Global undo/redo routes through +`IUndoRedoCoordinator` to the LCModel action handler, then refreshes the +region. Cancel rolls back the session and must not create a committed undo +action. Refresh coordination mirrors legacy +`DoNotRefresh`/`RefreshListNeeded` semantics via the refresh-coordinator +seam. + +**Canonical code.** `Src/Common/FwAvalonia/Seams/ISeams.cs`, +`SeamImplementations.cs`, `RefreshCoordinator.cs`. +Tests: `SeamTests.cs`, `RegionEditingTests.cs`. + +**Gotchas.** Two undo stacks produce user-visible data weirdness. Never +disable global undo while a session is dirty — route it. Defer PropChanged +fan-out during multi-field edits until commit/cancel. + +## 9. Validation + +**Decision.** Validation runs over immutable presentation snapshots, not +live LCModel. Errors are ordered by presentation/focus order (deterministic +for headless tests), skip unmaterialized lazy items, and carry node id, +object/flid, severity, localized message key + args, and accessibility +text. Only severity=Error blocks save; warnings do not. Stale async results +(from older snapshots) are discarded. + +**Canonical code.** `IValidationService` in +`Src/Common/FwAvalonia/Seams/ISeams.cs`; composer wiring in +`Src/xWorks/FullEntryRegionComposer.cs`. + +## 10. Custom fields and ghost rows + +**Decision.** Stored view definitions contain `CustomFieldPlaceholder` +nodes (typed equivalent of legacy `customFields="here"`), expanded from +LCModel metadata at compile time. Custom fields are never baked into stored +definitions (they differ per project). Ghost rows ("type to add" +placeholders) are runtime UI state managed by the composer/model, never +stored layout structure. + +**Canonical code.** `ViewDefinitionModel.cs` (placeholder node kind), +composer expansion in `FullEntryRegionComposer.cs`. +Tests: `RegionCustomFieldRenderingTests.cs`. + +## 11. Localization lanes + +**Decision.** Two lanes: + +- **Field labels** resolve through the legacy StringTable lane + (`XmlUtils.GetLocalizedAttributeValue`, `strings-{locale}.xml`) at render + time; the IR carries `LocalizationKey` per node, never baked English. +- **Product messages** (Save, Cancel, validation, unsupported-row text) + live in `Src/Common/FwAvalonia/FwAvaloniaStrings.resx` with translator + comments. + +**Gotchas.** SDK-style csprojs need an explicit `` element +or the Crowdin satellite-assembly build +(`Build/Src/FwBuildTasks/Localization/ProjectLocalizer.cs`) fails — verify +when adding any new Avalonia project. English-on-Avalonia where legacy +shows translations is a parity failure, not cosmetics. + +## 12. Density and performance + +**Decision.** Visual density (row spacing, gutters, box heights) is owned +by FieldWorks density constants, measured against legacy WinForms +baselines. Performance budgets are measured, not estimated: capture legacy +init/populate/total timings with the characterization harness, then hold +Avalonia to within 20% of legacy total (or record an explicitly accepted +delta in the region manifest). + +**Canonical code.** `Src/Common/FwAvalonia/Poc/PocDensity.cs`; +legacy harness +`Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs`; +committed thresholds `DataTreeTimingBaselines.json` (same directory). +Tests: `VisualParityAndDensityTests.cs`. + +**Gotchas.** Validate virtualization against the large fixtures (253-slice +detail, 10k-row browse) before committing a control choice. Include the +150% DPI path — it exposes real layout regressions. diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md new file mode 100644 index 0000000000..9d004a031a --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/lessons-learned.md @@ -0,0 +1,70 @@ +# Lessons Ledger and Skill-Update Protocol + +The fieldworks-* migration skills only stay useful if every migration +updates them. This file defines (a) the routing table that maps each kind +of discovery to the exact place to record it, and (b) an append-only ledger +of migration retrospectives so future agents can see how the skill set +evolved and why. + +## Update protocol — where each discovery goes + +Run this at the end of every migration (workflow step 10 in SKILL.md), and +immediately whenever you hit a stale pointer mid-task. Make the edits in +the same PR as the migration. + +| You discovered… | Update | +| --- | --- | +| A new architectural pattern, or a refinement of an existing one | `architecture-patterns.md` — add/extend the numbered section (decision, why, canonical code, gotchas); add a row to the SKILL.md quick map if it is load-bearing | +| A new seam contract | `seam-catalog.md` §1/§2 plus its pivot trigger in §3 | +| A pivot trigger fired (decision re-evaluated) | Record the outcome inline in `seam-catalog.md` §3 and summarize in the ledger below | +| A new plugin for a custom slice class | `architecture-patterns.md` §5 canonical-code list; keep the burn-down test list current | +| A new gotcha / failure mode (interop, DPI, fonts, focus, threading, lifetime…) | The Gotchas paragraph of the matching `architecture-patterns.md` section; if it is a review smell, also add a red flag to the most relevant satellite skill | +| A new forbidden legacy symbol | `EngineIsolationAuditTests.cs` (the enforcement) and `parity-evidence.md` §4 (the documentation) — both in the same PR | +| A new evidence lane, artifact type, or evidence-language term | `parity-evidence.md` | +| A new mandatory step in the per-region process | `migration-checklist.md` (and the workflow list in SKILL.md if it is a new phase) | +| A trigger phrase that failed to invoke a skill when it should have | The `description` frontmatter of that skill — add the missing vocabulary; keep descriptions quoted (YAML colons) and third-person | +| A stale file pointer (file moved/renamed, openspec change archived) | Fix the pointer in whichever skill file holds it; prefer pointing at code and tests over change docs | +| Updated performance baselines | `DataTreeTimingBaselines.json` stays the source of truth; update budget notes in `parity-evidence.md` §5 only if the policy (not the numbers) changed | + +Rules of thumb: + +- **Skills point, references explain, openspec records provenance.** Do + not paste large doc content into skills; capture the durable decision and + point at code/tests, citing the openspec doc as provenance. +- **Generalize before writing.** Record the class of problem, not the + one-off instance. If it only applies to one region, it goes in that + region's openspec change, not here. +- **Prune as you add.** If a section no longer pays for its tokens + (pattern superseded, gotcha fixed at the framework level), delete or + collapse it. Skills are working memory, not an archive — the archive is + git history and openspec. +- **Keep SKILL.md bodies under ~150 lines** and references one level deep + from SKILL.md. If a reference outgrows ~300 lines, split it by domain + and update the pointers. + +## Ledger + +Append one entry per completed migration (newest first). Keep entries to +~10 lines: link to the change, what was migrated, what was learned, which +skill files changed. + +### 2026-06 — Lexical Edit (full entry view), phases 1–2 (seed entry) + +- Change: `openspec/changes/lexical-edit-avalonia-migration/` (plus + `avalonia-migration-roadmap`, `lexical-edit-avalonia-poc-spike`). +- Migrated: first Avalonia lexical-edit region — typed IR pipeline, region + composer, owned field controls (`FwMultiWsTextField`, `FwOptionPicker`, + menus/flyouts), plugin registry, surface selection service, seam + contracts, Path 3 parity harness. +- Key lessons now encoded: boundary above DataTree (don't extract + internals); owned dense controls over stock grids; explicit + unsupported rows over silent fallback; one global undo stack; WinForms + dialogs own all modality during coexistence; measured (not estimated) + performance budgets; StringTable + `.resx` dual localization lanes; + `` required for Crowdin satellite builds. +- Skill set restructured (this commit): skills moved from + `.github/skills/` to `.claude/skills/` per AI_GOVERNANCE no-mirror rule; + hub skill rewritten with references/ (architecture-patterns, seam-catalog, + parity-evidence, migration-checklist, this ledger); satellite skill + descriptions rewritten for triggering; fixed YAML-colon bug that broke + `fieldworks-ui-wiring-review` triggering. diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/migration-checklist.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/migration-checklist.md new file mode 100644 index 0000000000..9536b857df --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/migration-checklist.md @@ -0,0 +1,100 @@ +# Per-Region Migration Checklist + +Copy this checklist into your working notes (or the OpenSpec change tasks) +at the start of a migration and keep it updated. It is the per-region +definition of done. Items map to the workflow phases in SKILL.md. + +## Phase 1 — Inventory and scope + +- [ ] Legacy surface identified: entry points, layouts/parts, custom slice + classes, dialogs, choosers, command/listener wiring +- [ ] Custom slice class census taken and compared against the plugin + registry (`Src/xWorks/RegionEditorPlugins.cs`) — list of missing + plugins recorded +- [ ] Coverage map drafted (behavior × test status: covered / deferred + with owner / blocked by named seam) +- [ ] Branch scope reviewed with `fieldworks-migration-scope-review` + (branch-only diff, split triggers checked) + +## Phase 2 — Characterize before refactor + +- [ ] Semantic baseline captured for the legacy surface (bindings, labels, + editor kinds, visibility, ghost state, focus order, WS metadata, + accessibility identity) +- [ ] Legacy timing baseline measured and committed +- [ ] Legacy UIA smoke coverage exists for launcher/chooser reachability +- [ ] All characterization tests run via `./test.ps1` (not branch-only lanes) + +## Phase 3 — Seams + +- [ ] Existing seams reused from `Src/Common/FwAvalonia/Seams/ISeams.cs` +- [ ] Any new seam added to `references/seam-catalog.md` with purpose, + rules, and pivot trigger +- [ ] No region code reaches directly into PropertyTable/mediator/LCModel + outside a seam + +## Phase 4 — Controls + +- [ ] Control choices follow architecture-patterns.md §4 (owned controls; + bounded TreeView ceiling respected) +- [ ] Any deviation justified by a fired pivot trigger, recorded in + seam-catalog.md §3 + +## Phase 5 — Region composition + +- [ ] Composer walks compiled IR; region model keyed by StableId +- [ ] Custom classes resolve plugin → companion strip → explicit + unsupported row (no silent fallback) +- [ ] Custom-field placeholders expand from LCModel metadata at compile + time; ghost rows are runtime state only +- [ ] Stable AutomationIds derived from StableId + (`{StableId}`, `{StableId}.Label`, `{StableId}.{WsAbbrev}`) + +## Phase 6 — Host wiring + +- [ ] Every affected host has an explicit `HostUiBehavior` (supported / + explicit legacy fallback / blocked) +- [ ] Full wiring path traced: setting source → persisted state → + PropertyTable key → broadcast → listener → host reload → focus and + command routing → save/`PrepareToGoAway()` → fallback +- [ ] Active-host contract holds: no hidden legacy DataTree/menu/renderer + driven while Avalonia is active +- [ ] Reviewed with `fieldworks-ui-wiring-review` + +## Phase 7 — Parity evidence + +- [ ] Path 3 bundle produced per scenario (see parity-evidence.md §1) +- [ ] Semantic, visual, workflow, and performance lanes each prove their + own axis; no lane substitutes for another +- [ ] Performance within budget (≤ legacy total × 1.2, or accepted delta + recorded) +- [ ] 100% and 150% DPI captured + +## Phase 8 — Localization + +- [ ] Field labels resolve through the StringTable lane via the IR's + `LocalizationKey` +- [ ] Product messages in `FwAvaloniaStrings.resx` with translator comments +- [ ] New csprojs carry `` (Crowdin satellite build) +- [ ] AutomationIds nonlocalized; automation Names localized +- [ ] Reviewed with `fieldworks-localization-review` + +## Phase 9 — Retirement and gates + +- [ ] Forbidden-symbol audit passes (`EngineIsolationAuditTests.cs`); + new forbidden symbols added to the audit and parity-evidence.md §4 +- [ ] Active-host contract tests pass + (`RecordEditViewActiveHostContractTests.cs`) +- [ ] `./build.ps1` and `./test.ps1` pass; `openspec validate + --strict` passes when an OpenSpec change is attached +- [ ] Legacy code scheduled for removal is listed explicitly (what, when, + behind which gate) + +## Phase 10 — Retrospective (updates this skill set) + +- [ ] New patterns/gotchas/pivots recorded per the protocol in + `references/lessons-learned.md` +- [ ] New plugins added to the canonical examples in + architecture-patterns.md §5 +- [ ] Stale file pointers in any fieldworks-* skill fixed +- [ ] Skill edits included in the same PR as the migration diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md new file mode 100644 index 0000000000..47c3da62b0 --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/parity-evidence.md @@ -0,0 +1,108 @@ +# Parity Evidence — Shared Definitions + +Canonical home for the evidence vocabulary used across all fieldworks-* +migration skills. Other skills reference these definitions instead of +redefining them. + +Contents: + +1. The Path 3 bundle (triangulated parity evidence) +2. Evidence lanes and what each can prove +3. Evidence language (claim-downgrading taxonomy) +4. Forbidden symbols (engine isolation) +5. Performance budgets +6. Artifact naming + +## 1. The Path 3 bundle (triangulated parity evidence) + +"Path 3" is the migration-quality visual-fidelity lane. A single artifact +cannot prove a region is migrated; the bundle triangulates. For one +scenario id, produce: + +- `semantic.json` — semantic snapshot of the IR/region (the anchor; both + legacy import and Avalonia compose must produce it) +- `visual.legacy.png` — WinForms screenshot (100% and 150% DPI) +- `visual.avalonia.png` — Avalonia capture, same framing/DPI + (Avalonia.Headless rendered frames are acceptable when the scenario is + explicitly control-scoped) +- diff/variance artifact interpreted against stable binding/focus/ + accessibility identity — never a raw pixel diff in isolation +- `workflow.legacy.md` / `workflow.avalonia.md` — accessibility/keyboard + workflow evidence (UIA2 on realized windows for desktop claims) +- `performance.json` — init/populate/refresh timings +- `failure-summary.md` — when something fails, classify the broken lane + with diagnostics; do not hand reviewers a raw image diff + +Canonical harness: `Src/Common/FwAvalonia/FwAvaloniaTests/Path3BundleTests.cs`; +bundle contract provenance: +`openspec/changes/lexical-edit-avalonia-migration/coverage-map.md` §9. + +## 2. Evidence lanes and what each can prove + +| Lane | Tooling | Proves | Cannot prove | +| --- | --- | --- | --- | +| Semantic snapshot | IR `ToSnapshot()`, JSON per scenario | Binding, labels, editor kind, visibility, ghost state, focus order, WS metadata, accessibility identity | Typography, density, wrapping, native rendering | +| Visual/render | Avalonia.Headless Skia capture; legacy screenshots | Layout, density, fonts, wrapping | Why a field is missing (semantic lane explains that) | +| Workflow/accessibility | Avalonia.Headless input simulation; UIA2/FlaUI on realized windows | Focus movement, invoke paths, chooser reachability, keyboard shortcuts, native automation tree | Pixel fidelity | +| Performance | Timing harness + committed baselines | Budgets vs. measured legacy | Anything functional | + +Headless and desktop are different lanes: a headless smoke test is never a +UIA2 baseline, and desktop workflow claims need realized-window evidence. + +## 3. Evidence language (claim-downgrading taxonomy) + +When verifying task checkboxes or PR claims, scan the evidence text for +these words and downgrade the claim accordingly: + +- **substitute** — a different artifact stands in for the claimed one; + the claim is unproven. +- **placeholder** — data or metadata is fake; parity is not demonstrated. +- **skipped** — the test exists but did not run; no evidence. +- **future / planned** — work item, not evidence. +- **partial** — name exactly which axes are proven and which are not. + +A checked task whose evidence says any of the above is a review blocker: +either the evidence improves or the checkbox is unchecked. + +## 4. Forbidden symbols (engine isolation) + +Migrated-region production code must not reference, in any runtime path: + +- `System.Windows.Forms.Control` +- `DataTree`, `Slice`, `SliceFactory`, `RootSiteControl` +- `XmlView`, `BrowseViewer` +- `IVwRootBox`, `IVwEnv`, `IVwGraphics`, `IRenderEngine` +- `GraphiteEngineClass`, `UniscribeEngineClass` +- `GeckoWebBrowser`, `XWebBrowser`, `GeckofxHtmlToPdf` + +Enforced by `Src/Common/FwAvalonia/FwAvaloniaTests/EngineIsolationAuditTests.cs`. +When a migration discovers a new legacy symbol that must not leak, add it +to the audit test AND this list in the same PR. Custom linguistic services +(XAmple, spelling, parsers, ICU, encoding converters) may remain behind +explicit service seams when they do not own the render/editor surface. + +## 5. Performance budgets + +Budgets are measured, never estimated: + +1. Capture legacy timings with the characterization harness + (`Src/Common/Controls/DetailControls/DetailControlsTests/DataTreeRenderTests.cs`) + on a named machine profile; thresholds are committed in + `DataTreeTimingBaselines.json`. +2. Hold the Avalonia surface to within 20% of measured legacy total, or + record an explicitly accepted delta with justification in the region + manifest. +3. Measure cold vs. warm separately; IR compile latency must stay + deterministic and small; refresh-after-edit has its own baseline. +4. Always include the 150% DPI path and the large fixtures before + accepting a control choice. + +## 6. Artifact naming + +`{scenarioId}/{bundleId}/semantic.json`, `visual.legacy.png`, +`visual.avalonia.png`, `workflow.legacy.md`, `workflow.avalonia.md`, +`performance.json`, `failure-summary.md`. + +Snapshots must normalize away pixel bounds, transient generated names, +timestamps, machine paths, and culture-dependent ordering — see +`fieldworks-semantic-render-parity` for include/exclude rules. diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/seam-catalog.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/seam-catalog.md new file mode 100644 index 0000000000..98337847db --- /dev/null +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/seam-catalog.md @@ -0,0 +1,76 @@ +# Seam Catalog + +The seams that separate Avalonia UI from LCModel/xCore/WinForms. All +contracts live in `Src/Common/FwAvalonia/Seams/ISeams.cs` with +implementations in `SeamImplementations.cs` and tests in +`Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs`. Per-seam design docs +(current state, alternatives considered, required tests) live in +`openspec/changes/lexical-edit-avalonia-migration/avalonia-*.md`. + +Before inventing a new abstraction for a migration, check this table. If a +seam fits, reuse it. If none fits, add the new seam here (name, purpose, +rules, pivot trigger) in the same PR that introduces it. + +Contents: + +1. Seam table +2. Supporting seams +3. Pivot triggers (when to revisit a decision) + +## 1. Seam table + +| Seam | Purpose | Key rules | +| --- | --- | --- | +| `IEditSession` | Fenced LCModel undo-task lifecycle: Active → Saved/Canceled → Disposed | One undoable action per save; cancel rolls back without creating an undo action; writes outside a session are a bug | +| `IUndoRedoCoordinator` | Routes global undo/redo through the LCModel action handler | Control-local text undo stays local until commit; never a parallel committed-state history; refresh region after global undo/redo | +| `IValidationService` | Deterministic validation over immutable presentation snapshots | Focus-order error ordering; skip unmaterialized lazy items; localized message keys; only severity=Error blocks save; discard stale async results | +| `IXCoreCommandBridge` | Bridges xCore mediator command routing to Avalonia commands | Region-local commands first; shell-scope wiring happens in the shell phase, not per region | +| `IUiScheduler` | Thin UI-thread marshalling (`IsOnUiThread`, `Post`) | No hidden `Task.Run`; fakeable in tests; keeps threading visible at the seam | +| `IRegionLifetime` | Region disposal discipline | Idempotent disposal, late-callback suppression, event-handler cleanup; protects against async work completing after close | +| `ILexicalRefreshCoordinator` | Mirrors legacy `DoNotRefresh`/`RefreshListNeeded` gating (LT-22414) | Defer PropChanged fan-out during multi-field edits until commit/cancel; characterize legacy behavior before extending (`RefreshCoordinator.cs`) | +| `IRecordNavigationContext` | Bidirectional selection bridge with the xCore "current record" bus | Follow external navigation and publish selection back; never reach into PropertyTable directly from a region | +| `IFwClipboard` | Clipboard access without WinForms dependency | See `FwClipboardSeamTests.cs` | +| `IHostSurface` (focus API) | Host-side focus save/restore around WinForms dialogs | Pairs with the dialog-ownership rules (architecture-patterns.md §7) | + +## 2. Supporting seams + +- **View definition pipeline:** `IViewDefinitionImporter` / + `ViewDefinitionCompiler` / cache keyed by immutable source snapshot + (`ViewDefinitionCacheKey.cs`). Off-thread compilation, deterministic + output. +- **Region value provision:** the composer consumes value-provider style + seams so region models can be built and tested without LCModel or + WinForms. +- **Active-host contract:** `ActiveHostContract.cs` whitelists the only + approved adapters through which an active Avalonia host may touch legacy + infrastructure. +- **Drag/drop and sync-context hygiene:** `FwDragDrop.cs`, + `FinalizerSafeSynchronizationContext.cs`. + +## 3. Pivot triggers (when to revisit a decision) + +A decision below stands until its trigger fires. When a trigger fires, +record the re-evaluation outcome here and in the lessons ledger. + +- **Edit sessions:** adopt a staged-draft model only if fenced direct + LCModel sessions prove unacceptably complex or risky in practice. +- **Undo/redo:** add richer document-local undo only for a specific owned + control that needs it, still committing through LCModel. +- **Validation:** collapse to Avalonia-native validation only for isolated + dialogs with no LCModel/cross-object semantics. +- **UI scheduler / lifetime:** collapse wrappers that demonstrably provide + no test or architecture value. +- **TreeDataGrid:** re-evaluate for browse surfaces if it is relicensed + permissively (or SIL accepts a commercial license) AND upstream closes + the editing/automation gaps. +- **VirtualizingStackPanel:** escalate to a fully owned realization-window + virtualizer if scroll/expand or open-time budgets fail on the production + fixtures (253-slice detail, 10k-row browse). +- **TreeView ceiling (≤500 items):** raise/remove if a consumable Avalonia + release ships TreeView virtualization. +- **ItemsRepeater:** reconsider as the owned-control substrate if it is + un-deprecated with maintained virtualization. +- **Owned-control cost:** if owned controls overrun, re-open the + TreeDataGrid commercial option with measured cost as the baseline. +- **Stock-control accessibility:** if any adopted stock control fails an + accessibility gate, owning its automation peers becomes mandatory. diff --git a/.github/agents/fieldworks.avalonia-expert.agent.md b/.github/agents/fieldworks.avalonia-expert.agent.md index 8933db04a8..23895a1783 100644 --- a/.github/agents/fieldworks.avalonia-expert.agent.md +++ b/.github/agents/fieldworks.avalonia-expert.agent.md @@ -28,6 +28,20 @@ When you need Avalonia API details, patterns, or examples, you MUST use Context7 - Build: `./build.ps1` - Tests: `./test.ps1` +## Repo skills (read before designing anything) +The migration playbook and decided architecture live in skills under +`.claude/skills/` (picked up by Copilot and Claude Code alike): +- `fieldworks-winforms-to-avalonia-migration` — hub playbook; its + `references/architecture-patterns.md` and `references/seam-catalog.md` + document the decided patterns (typed IR, region composer, owned controls, + seams). Do not reinvent abstractions those files already settle. +- `fieldworks-avalonia-ui` — control/XAML/headless-test conventions and + canonical code to imitate. +- Supporting reviews: `fieldworks-ui-wiring-review`, + `fieldworks-uia2-parity-testing`, `fieldworks-semantic-render-parity`, + `fieldworks-localization-review`, `fieldworks-migration-scope-review`, + `fieldworks-managed-netfx-review`. + ## Avalonia development guidelines - Prefer MVVM patterns that are idiomatic for Avalonia. - Keep UI logic out of XAML code-behind where practical; use view models and bindings. diff --git a/.github/instructions/avalonia.instructions.md b/.github/instructions/avalonia.instructions.md index e6b2590908..134546216a 100644 --- a/.github/instructions/avalonia.instructions.md +++ b/.github/instructions/avalonia.instructions.md @@ -9,6 +9,11 @@ description: "Guidance for FieldWorks Avalonia modules and the shared Preview Ho ## Purpose & Scope - Provide a consistent way to **create, build, test, and preview** Avalonia UI modules in FieldWorks. - Applies to the Advanced Entry Avalonia work under `specs/010-advanced-entry-view/` and future Avalonia modules. +- This file covers mechanics (build, layout, logging, preview). The + migration playbook, decided architecture patterns, and parity/evidence + rules live in the skills under `.claude/skills/` — start with + `fieldworks-winforms-to-avalonia-migration` (hub) and + `fieldworks-avalonia-ui`. ## Key Rules diff --git a/.github/skills/fieldworks-avalonia-ui/SKILL.md b/.github/skills/fieldworks-avalonia-ui/SKILL.md deleted file mode 100644 index a140684e84..0000000000 --- a/.github/skills/fieldworks-avalonia-ui/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: fieldworks-avalonia-ui -description: Use when creating, reviewing, or fixing Avalonia UI modules in FieldWorks, especially XAML, MVVM, preview-host, localization, accessibility, product-vs-preview wiring, or net48/net8 Avalonia test changes. ---- - -# FieldWorks Avalonia UI - -## Use This For -- Avalonia XAML, view models, commands, lifetimes, dispatching, and resource/style changes. -- New or changed projects under `Src/**/**/*.Avalonia/`, `Src/Common/FwAvalonia/`, and `Src/Common/FwAvaloniaPreviewHost/`. -- Preview Host module registration, sample data providers, and UI diagnostics. -- Global or per-screen UI host wiring that selects between Avalonia and legacy UI, including app-setting, `PropertyTable`, mediator, and product-vs-preview routing changes. - -## Required Checks -- Use current Avalonia docs for uncertain APIs; do not guess dispatcher, headless, automation, or binding behavior. -- Keep product UI strings localizable; prototype hardcoded strings must be called out as gaps. -- Stable accessibility identity belongs on user-facing controls via Avalonia automation properties. -- UI work should stay in bindings/view models where practical; avoid logic-heavy code-behind. -- Keep module preview data lightweight unless the change explicitly opts into LCModel/project data. -- If a change touches a UI mode or host switch, trace the full wiring path: setting source, `PropertyTable`/mediator broadcast, listener registration, host reload path, focus/command routing, and explicit fallback state for every affected consumer. -- Global runtime switches are product behavior. Audit every affected host/consumer, not only the first lexical surface. -- Product-facing Avalonia paths must use real edit-session/domain contracts; detached DTO-only models remain preview-only. -- Preserve repo build/test entry points: `./build.ps1` and `./test.ps1`, and make sure Avalonia projects/tests are covered through the normal repo graph rather than only through optional branch-specific lanes. -- For Path 3 visual parity, remember the official Avalonia behavior: headless tests can simulate keyboard/mouse/text input on `Window`, `Dispatcher.UIThread.RunJobs()` flushes deferred UI work, and visual regression capture requires Skia + `UseHeadlessDrawing=false` with `CaptureRenderedFrame()`. -- Stamp stable `AutomationProperties.Name` and `AutomationProperties.AutomationId` on user-facing controls that participate in parity bundles so the UIA/accessibility lane can identify them reliably. - -## Review Red Flags -- A Common project directly references a feature module without an explicit architecture decision. -- Preview-only code is launched from product UI without a feature gate and real-project behavior story. -- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar handlers instead of proving the real runtime broadcast/wiring path. -- The active Avalonia path still initializes or drives hidden legacy rendering/menu infrastructure without an explicit approved baseline-only reason. -- A product-facing route uses preview host code, preview DTOs, or a lossy mapper as if it were a migrated surface. -- Optional or branch-specific Avalonia build/test lanes are treated as the only integration evidence. -- Sleep-based or timing-sensitive UI tests. -- Claims of accessibility, localization, IME, or keyboard parity without executable evidence. - -## Handoff -Report exact Avalonia docs consulted, tests run, remaining prototype gaps, whether the change is product-facing or preview-only, and how the live wiring path was validated for each affected host. For Path 3 work, say whether the visual evidence is control-level headless capture or live desktop capture, and which accessibility identities were assigned via `AutomationProperties`. \ No newline at end of file diff --git a/.github/skills/fieldworks-localization-review/SKILL.md b/.github/skills/fieldworks-localization-review/SKILL.md deleted file mode 100644 index e9d3b04684..0000000000 --- a/.github/skills/fieldworks-localization-review/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: fieldworks-localization-review -description: Use when reviewing or changing FieldWorks user-facing strings, `.resx` resources, localization keys, Crowdin-facing assets, or localization-sensitive automation metadata. ---- - -# FieldWorks Localization Review - -## Use This For -- Product-facing text in WinForms, Avalonia, settings UI, dialogs, validation messages, fallback or unsupported-surface text, and promoted preview paths. -- `.resx` additions or changes, localization key flow, and Crowdin-sensitive resource updates. -- Automation metadata where `Name`, tooltip, or label is localized but stable `AutomationId` must remain nonlocalized. - -## Required Checks -- Product-facing user-visible strings live in `.resx` or the established localization mechanism; preview-only hardcoded text must stay clearly preview-only. -- New UI mode labels, fallback or unsupported messages, validation errors, and diagnostics are localized before a product path is exposed. -- Stable `AutomationId` and other selectors remain nonlocalized; localized names, tooltips, and labels may vary by locale. -- Resource keys and files align with existing Crowdin and repo localization conventions. -- If localization parity is claimed, tests or evidence cover the localized path and confirm selectors do not depend on localized text. - -## Review Red Flags -- Hardcoded English text in product C#, XAML, or product-facing preview-promotion paths. -- Tests or automation selectors depend on localized labels when stable IDs exist or are required. -- A product route reuses preview-only placeholder text. -- Localization claims are made without resource updates or without identifying remaining hardcoded strings. - -## Handoff -List the resource files or keys touched, remaining hardcoded product strings, automation identity strategy, and whether localized behavior has executable evidence or is still pending. \ No newline at end of file diff --git a/.github/skills/fieldworks-managed-netfx-review/SKILL.md b/.github/skills/fieldworks-managed-netfx-review/SKILL.md deleted file mode 100644 index a74d3b4447..0000000000 --- a/.github/skills/fieldworks-managed-netfx-review/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: fieldworks-managed-netfx-review -description: Use when reviewing or changing FieldWorks managed C# projects that cross .NET Framework 4.8, C# 7.3, SDK-style net8, tests, or project-file boundaries. ---- - -# FieldWorks Managed NetFx Review - -## Compatibility Split -- Legacy product code is .NET Framework 4.8 and C# 7.3 unless a project explicitly targets modern .NET. -- New Avalonia modules may target `net8.0-windows`; do not leak C# 8+ syntax or net8-only APIs into net48 projects. -- Legacy `.csproj` files require explicit source inclusion; SDK-style projects have different defaults. - -## Required Checks -- User-visible strings use `.resx` patterns where product-facing. -- UI and async code marshals to the correct UI thread and does not use sync-over-async. -- Disposable WinForms/GDI/LCModel/test resources are owned and disposed deterministically. -- Test discovery changes must be validated across both net48 and net8 test assemblies. -- Use repo scripts for evidence: `./build.ps1` and `./test.ps1`. - -## Review Red Flags -- Nullable annotations, records, file-scoped namespaces, switch expressions, or `using var` in net48/C# 7.3 projects. -- Broad project/test-runner changes justified only by one local test passing. -- Hardcoded Debug paths or absolute repo assumptions in tests. -- Skipped tests used as evidence of covered behavior. - -## Handoff -Report target frameworks touched, project-file implications, test commands/results, and any remaining compatibility risks. \ No newline at end of file diff --git a/.github/skills/fieldworks-migration-scope-review/SKILL.md b/.github/skills/fieldworks-migration-scope-review/SKILL.md deleted file mode 100644 index a813eb99c0..0000000000 --- a/.github/skills/fieldworks-migration-scope-review/SKILL.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: fieldworks-migration-scope-review -description: Use when reviewing large FieldWorks migration PRs, OpenSpec changes, foundational branches, scope splits, draft PR readiness, or evidence claims. ---- - -# FieldWorks Migration Scope Review - -## Review Posture -Treat foundational migration PRs as architecture and evidence packages. The main question is whether reviewers can trust the scope, claims, and validation boundary. - -## Required Checks -- Scope review is branch-relative: compare `main..HEAD` or the merge-base diff, not calendar-time commit lists. Same-day commits already on `main` are not branch scope. -- Compare PR title/body/tasks against the actual diff. -- Classify files as plan/spec, characterization test, infrastructure, prototype, product behavior, or unrelated change. -- When product or global UI wiring appears, trace preview-vs-product routing and host/listener wiring separately from plan/test changes. -- Verify checked tasks match evidence language; downgrade claims when evidence says substitute, placeholder, skipped, future, or partial. -- Confirm validation gates are explicit: OpenSpec validation, targeted tests, normal `./build.ps1` and `./test.ps1` coverage for Avalonia, and `CI: Full local check` when ready. - -## Split Triggers -- Product-visible behavior appears in a planning/test PR. -- Branch-only diff mixes product-visible wiring with planning/test/docs/prototype work. -- Common infrastructure directly depends on the first feature module without an explicit decision. -- Test-runner/build graph changes are mixed with UI migration work. -- Unrelated behavior changes require their own review context. - -## Review Red Flags -- A draft PR is so broad that each reviewer must reverse-engineer intent. -- Scope complaints are based on "commits made today" instead of the branch-only diff against `main`. -- Evidence is stale after rebase or differs from visible CI state. -- A prototype is wired as if it were a product feature. - -## Handoff -Lead with blockers, then list what to remove, split, reword, or validate before review. Call out false scope signals separately from real branch-only scope problems. \ No newline at end of file diff --git a/.github/skills/fieldworks-semantic-render-parity/SKILL.md b/.github/skills/fieldworks-semantic-render-parity/SKILL.md deleted file mode 100644 index b659a18b0b..0000000000 --- a/.github/skills/fieldworks-semantic-render-parity/SKILL.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -name: fieldworks-semantic-render-parity -description: Use when capturing or reviewing FieldWorks semantic snapshots, render baselines, layout parity, failure artifacts, XML view definitions, or Avalonia presentation IR. ---- - -# FieldWorks Semantic Render Parity - -## Snapshot Discipline -Semantic snapshots should preserve behaviorally meaningful identity and omit incidental layout noise. - -## Include -- Stable node ID and source layout/part identity. -- When a scenario can run through multiple hosts or fallback states, record which route produced the artifact (`Avalonia`, legacy fallback, or blocked state). -- Object/class binding, field/flid binding, editor kind, writing-system metadata, visibility, ghost state, expansion, focus order, localization key, and accessibility identity. -- Unsupported construct diagnostics with enough path context to fix the source layout. - -## Exclude Or Normalize -- Pixel bounds, transient generated names, timestamps, machine paths, culture-dependent ordering, and realized-control counts unless the test explicitly owns them. - -## Render Evidence -- Pixel/render tests need deterministic fixtures, clear thresholds, and failure artifacts that reviewers can inspect. -- A semantic snapshot is not a substitute for visual/render parity when typography, density, wrapping, or native rendering seams are under review. - -## Path 3 Bundle -For migration-quality visual fidelity, prefer a triangulated bundle instead of a single artifact lane: - -- semantic snapshot, -- visual evidence for legacy WinForms and Avalonia, -- diff/variance artifact, -- workflow/accessibility evidence, -- one failure summary that classifies the broken lane. - -Use the semantic snapshot as the anchor. Visual variance should be interpreted against stable binding/focus/accessibility identity, not in isolation. - -Control-level Avalonia visual evidence may come from Avalonia.Headless rendered frames when the scenario is explicitly control-scoped. Desktop workflow/accessibility claims still need live-window evidence. - -## Review Red Flags -- A preview-only or lossy route is presented as if it proved product parity. -- Placeholder metadata is presented as real binding or writing-system parity. -- Snapshot tests update large JSON blobs without a small behavioral explanation. -- Cache invalidation tests depend on sleeps or filesystem timestamp luck. - -## Handoff -State whether evidence is semantic, visual, accessibility/workflow, or performance parity, and identify remaining unproven axes. When a Path 3 bundle is used, name each artifact and which lane it proves. \ No newline at end of file diff --git a/.github/skills/fieldworks-ui-wiring-review/SKILL.md b/.github/skills/fieldworks-ui-wiring-review/SKILL.md deleted file mode 100644 index c655e5cf7a..0000000000 --- a/.github/skills/fieldworks-ui-wiring-review/SKILL.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: fieldworks-ui-wiring-review -description: Use when reviewing or changing FieldWorks UI wiring: app-setting or `PropertyTable` routing, mediator notifications, current-content switching, host replacement, preview-vs-product boundaries, or global legacy-vs-Avalonia UI selection. ---- - -# FieldWorks UI Wiring Review - -## Use This For -- Global or screen-level UI mode selection. -- `PropertyTable`, app-setting, mediator, or listener changes that affect which UI host is active. -- `RecordEditView`, `currentContentControl`, host replacement, save or `PrepareToGoAway()` routing, focus or command target routing, and preview-to-product promotion work. - -## Required Checks -- Review scope against the branch-only diff (`main..HEAD`) and list every host or consumer affected. -- Trace the full wiring path end to end: setting source, persisted state, `PropertyTable` key, mediator or property broadcast, listener registration, host reload path, focus or command target routing, save or `PrepareToGoAway()` path, and fallback or blocked state. -- For global switches, verify each current consumer has an explicit contract: supported Avalonia surface, explicit legacy fallback, or resource-backed unsupported state. -- The active Avalonia route must not instantiate or drive hidden legacy rendering or menu infrastructure except through explicitly approved baseline-only adapters. -- Product wiring and preview wiring must be reviewed separately; preview DTOs, preview hosts, and spike-only semantics do not satisfy product routing. -- Validation must use the normal repo build and test path (`./build.ps1`, `./test.ps1`) plus host-specific tests when wiring changes. - -## Review Red Flags -- Tests manually call `OnPropertyChanged(...)`, `ShowRecord()`, or similar handlers instead of driving the real setting and broadcast path. -- A preview-only mapper or detached DTO model sits on a product-facing route. -- Hidden legacy `DataTree`, menu handler, or renderer is still initialized and driven while Avalonia is the active host. -- A global setting changes unrelated screens without a manifest or explicit fallback story. -- Build or test evidence relies mainly on branch-only optional lanes or ad hoc commands. - -## Handoff -Report the setting source, listeners, affected hosts, per-host fallback state, executable proof of the live wiring path, and any remaining hidden legacy dependencies. \ No newline at end of file diff --git a/.github/skills/fieldworks-uia2-parity-testing/SKILL.md b/.github/skills/fieldworks-uia2-parity-testing/SKILL.md deleted file mode 100644 index 9e0f6795c9..0000000000 --- a/.github/skills/fieldworks-uia2-parity-testing/SKILL.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -name: fieldworks-uia2-parity-testing -description: Use when designing or reviewing FieldWorks UI automation, UIA2, FlaUI, Appium, WinAppDriver, Avalonia.Headless, accessibility, keyboard, focus, or IME parity tests. ---- - -# FieldWorks UIA2 Parity Testing - -## Lane Separation -- Avalonia.Headless is for fast in-process control, layout, view-model, binding, and input tests. -- UIA2/FlaUI/Appium/WinAppDriver tests require realized desktop windows and validate native accessibility trees, focus, invoke patterns, and product integration. -- Do not call a headless smoke test a UIA2 baseline. - -## Path 3 Role -In a Path 3 parity bundle, UIA2/FlaUI/Appium contributes the workflow/accessibility lane only: - -- launcher/chooser reachability, -- focus movement and focus return, -- invoke/cancel/accept paths, -- native automation tree identity, -- shell-level keyboard behavior. - -It does not replace semantic snapshots or visual/render evidence. A desktop automation result should be reported alongside the semantic and visual artifacts for the same scenario id. - -## Required Evidence -- Stable automation IDs or accessible names for controls under test. -- Explicit coverage of focus movement, invoke/click path, popup/chooser reachability, keyboard shortcuts, and failure artifacts. -- When UI mode or host wiring changes, desktop automation must cover the real switch-driven host refresh or fallback behavior on realized windows; manual handler calls or headless-only assertions do not prove product wiring. -- Clear CI lane: headless can run broadly; desktop automation needs an interactive Windows desktop or a configured automation host. - -## Review Red Flags -- “Runs in the background” used for UIA2/Appium without explaining the required desktop/session. -- Manual `OnPropertyChanged(...)` or similar handler invocation is presented as proof of live UI-mode wiring. -- Tests assert implementation internals instead of user-observable accessibility behavior. -- Automation selectors rely on localized labels when stable IDs are available or required. -- IME coverage is claimed without a real text editor/control surface and input-method evidence. - -## Handoff -Classify each test as headless, native desktop automation, or smoke substitute, and state what parity claim it can and cannot support. When used in a Path 3 bundle, say explicitly which workflow/accessibility assertions the desktop lane proved, whether switch wiring/fallback was exercised on a realized window, and which claims still need another lane. \ No newline at end of file diff --git a/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md b/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md deleted file mode 100644 index 2a11eebf0e..0000000000 --- a/.github/skills/fieldworks-winforms-to-avalonia-migration/SKILL.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: fieldworks-winforms-to-avalonia-migration -description: Use when planning, reviewing, or implementing FieldWorks WinForms/xWorks/DataTree/XMLViews migration paths to Avalonia, including seam extraction and parity coverage. ---- - -# FieldWorks WinForms To Avalonia Migration - -## Core Rule -Migrate by proving behavior first, extracting seams second, and introducing Avalonia controls only after legacy behavior has executable parity evidence. - -## Workflow -1. Prove current behavior, including global UI wiring and fallback behavior. -2. Extract clean seams and explicit host contracts before exposing new product wiring. -3. Promote Avalonia from preview to product only after persistence, localization, and parity evidence exists. -4. Keep WinForms fallback explicit until the migrated region manifest says otherwise. - -## Required Baselines -- Entry points: `RecordEditView`, `DataTree`, `SliceFactory`, XMLViews browse/table views, launchers, popup choosers, and command/listener wiring. -- Global wiring: app-setting source, `PropertyTable`/mediator broadcast, live host refresh, focus/command routing, and explicit fallback or blocked state for every affected host. -- Semantics: object/class binding, flid/field binding, labels, visibility, ghost state, expansion, focus order, writing-system metadata, accessibility identity, and localization keys. -- User workflows: create/edit/save/cancel, chooser OK/cancel, undo/redo, refresh/postponed `PropChanged`, keyboard focus restoration, and disposal/unsubscribe. - -## Architecture Checks -- Keep WinForms Designer-safe code isolated from extracted logic. -- Extract humble objects/services for modal decisions and data-loss classifiers before replacing controls. -- Put an editor registry or adapter boundary in front of legacy `SliceFactory` behavior before mixing legacy and Avalonia editors. -- Keep the global UI mode contract explicit: the switch may be app-wide, but each consumer must have a deliberate supported, fallback, or blocked state. -- Do not let the active Avalonia host instantiate or drive hidden legacy `DataTree` or menu infrastructure except through explicitly approved baseline adapters. -- Treat product command wiring as product behavior, not preview scaffolding. - -## Review Red Flags -- A PR mixes plans, tests, infrastructure, product UI wiring, and unrelated behavior changes. -- Tests manually invoke `OnPropertyChanged`, `ShowRecord`, or similar handlers to simulate runtime wiring. -- Active Avalonia routing depends on a lossy POC DTO mapper or partial `LexEntry`-only fallback without an explicit product contract. -- Avalonia integration is validated only through `-BuildAvalonia` or ad hoc commands instead of the normal repo build/test path. -- Task checkboxes claim UIA2/IME/accessibility/localization parity while evidence says substitute, placeholder, skipped, or future work. -- Avalonia preview data modifies or pretends to modify real project data without a real edit-session contract. - -## Handoff -State what is legacy baseline, what is extracted seam, what is Avalonia prototype, what each affected host does under the global switch, and what remains outside parity. \ No newline at end of file diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index fde5be579e..b8d1dd3c3c 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -69,5 +69,11 @@ public static class FwAvaloniaStrings /// "{0} settings" — accessible name of a chooser's hover-revealed settings gear. public static string FieldSettingsFormat => Resources.GetString("ksFieldSettings"); + + /// + /// "Edit the {0} list" — label/tooltip of a configure-gear jump derived from the row's + /// possibility list (the legacy chooser dialog's "Edit the … list" link text). + /// + public static string EditListFormat => Resources.GetString("ksEditListFormat"); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index a692819399..bae44a7f70 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -97,4 +97,8 @@ {0} settings Accessible name of the hover-revealed settings gear on a chooser field; {0} is the field label (e.g. "Morph Type settings"). + + Edit the {0} list + Tooltip/label of a configure gear whose list-editor jump was derived from the row's possibility list; {0} is the list name (mirrors the legacy chooser's "Edit the … list" links). + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs new file mode 100644 index 0000000000..115eb22904 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Poc; +using SIL.FieldWorks.Common.FwAvalonia.Region; + +namespace FwAvaloniaTests +{ + /// + /// The ONE compact filterable option picker (FwOptionPicker) behind every option dropdown: + /// a selection-filter panel (filter box auto-focused on open, watermarked with the search + /// prompt) over a VIRTUALIZED list capped at the density token height, item spacing pinned + /// to the compact legacy values (never the Fluent defaults), hierarchy preserved by Depth + /// indent. Typing filters live (case-insensitive contains for static options; the host + /// search delegate for search-backed pickers); Down/Up move the highlight, Enter commits + /// the highlighted option (first match by default), Escape dismisses, click commits. + /// + [TestFixture] + public class FwOptionPickerTests + { + private static IReadOnlyList Tree() => new List + { + new RegionChoiceOption("u", "Universe", 0), + new RegionChoiceOption("u-sky", "Sky", 1), + new RegionChoiceOption("u-weather", "Weather", 1), + new RegionChoiceOption("p", "Person", 0) + }; + + private static (FwOptionPicker picker, Window window, List committed, + int dismissed) ShowStatic() + { + var picker = new FwOptionPicker(Tree(), null, "Domains"); + var committed = new List(); + picker.OptionCommitted += committed.Add; + var dismissed = 0; + picker.Dismissed += (s, e) => dismissed++; + var window = new Window { Content = picker, Width = 400, Height = 420 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + return (picker, window, committed, dismissed); + } + + private static void RaiseKey(Control target, Key key) + { + target.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = key, + Source = target + }); + Dispatcher.UIThread.RunJobs(); + } + + private static IReadOnlyList Items(FwOptionPicker picker) + => (picker.OptionsList.ItemsSource as IEnumerable)?.ToList() + ?? new List(); + + [AvaloniaTest] + public void OpensWithFilterFocused_Watermarked_AndAllOptionsListed() + { + var (picker, _, _, _) = ShowStatic(); + + Assert.That(picker.FilterBox.IsFocused, Is.True, + "the filter box auto-focuses when the picker attaches (flyout open)"); + Assert.That(picker.FilterBox.Watermark, Is.EqualTo(FwAvaloniaStrings.SearchPrompt), + "the existing search prompt watermarks the filter"); + Assert.That(AutomationProperties.GetAutomationId(picker.FilterBox), Is.EqualTo("Domains.Search")); + Assert.That(AutomationProperties.GetAutomationId(picker.OptionsList), Is.EqualTo("Domains.Options")); + Assert.That(Items(picker).Select(o => o.Key), Is.EqualTo(new[] { "u", "u-sky", "u-weather", "p" }), + "static options enumerate up front, in list order"); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(0), "the first match is highlighted"); + } + + [AvaloniaTest] + public void Typing_FiltersLive_CaseInsensitiveContains() + { + var (picker, window, _, _) = ShowStatic(); + + window.KeyTextInput("EaTh"); // headless text input into the focused filter box + + Assert.That(picker.FilterBox.Text, Is.EqualTo("EaTh")); + Assert.That(Items(picker).Select(o => o.Name), Is.EqualTo(new[] { "Weather" }), + "a case-insensitive CONTAINS filter, not prefix-only"); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(0), + "the first (only) match is highlighted, ready for Enter"); + + picker.FilterBox.Text = string.Empty; + Dispatcher.UIThread.RunJobs(); + Assert.That(Items(picker), Has.Count.EqualTo(4), "clearing the filter restores all options"); + } + + [AvaloniaTest] + public void Enter_CommitsTheHighlightedOption_DefaultingToTheFirstMatch() + { + var (picker, window, committed, _) = ShowStatic(); + + window.KeyTextInput("sky"); + RaiseKey(picker.FilterBox, Key.Enter); + + Assert.That(committed, Has.Count.EqualTo(1), "Enter commits"); + Assert.That(committed[0].Key, Is.EqualTo("u-sky"), "the first match was highlighted by default"); + } + + [AvaloniaTest] + public void DownAndUp_MoveTheHighlight_AndEnterCommitsIt() + { + var (picker, _, committed, _) = ShowStatic(); + + RaiseKey(picker.FilterBox, Key.Down); + RaiseKey(picker.FilterBox, Key.Down); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(2), "Down moves the highlight"); + RaiseKey(picker.FilterBox, Key.Up); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(1), "Up moves it back"); + + RaiseKey(picker.FilterBox, Key.Enter); + Assert.That(committed.Single().Key, Is.EqualTo("u-sky"), "Enter commits the highlighted option"); + } + + [AvaloniaTest] + public void Escape_RaisesDismissed_WithoutCommitting() + { + var picker = new FwOptionPicker(Tree(), null, "Domains"); + var committed = new List(); + picker.OptionCommitted += committed.Add; + var dismissed = 0; + picker.Dismissed += (s, e) => dismissed++; + var window = new Window { Content = picker, Width = 400, Height = 420 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + RaiseKey(picker.FilterBox, Key.Escape); + + Assert.That(dismissed, Is.EqualTo(1), "Escape dismisses (the host hides its flyout)"); + Assert.That(committed, Is.Empty, "Escape never commits"); + } + + [AvaloniaTest] + public void HierarchyIndent_IsPreserved_ThroughTheDepthMargin() + { + var (picker, window, _, _) = ShowStatic(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var texts = picker.OptionsList.GetVisualDescendants().OfType() + .Where(t => Tree().Any(o => o.Name == t.Text)) + .ToDictionary(t => t.Text, t => t.Margin.Left); + Assert.That(texts["Universe"], Is.EqualTo(0), "top-level options sit flush"); + Assert.That(texts["Sky"], Is.EqualTo(14), "depth-1 options indent one level (B8 hierarchy)"); + Assert.That(texts["Weather"], Is.EqualTo(14)); + } + + [AvaloniaTest] + public void ItemDensity_IsPinnedCompact_NotTheFluentDefaults() + { + var (picker, window, _, _) = ShowStatic(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var container = picker.OptionsList.ContainerFromIndex(0) as ListBoxItem; + Assert.That(container, Is.Not.Null, "the first option's container is realized"); + Assert.That(container.Padding, Is.EqualTo(PocDensity.OptionItemPadding), + "item padding mirrors the legacy WinForms menu spacing (~6,2), not Fluent"); + Assert.That(container.MinHeight, Is.EqualTo(0d), "no Fluent minimum row height"); + } + + [AvaloniaTest] + public void List_IsVirtualized_AndHeightCapped_SoOffScreenContentScrolls() + { + var (picker, window, _, _) = ShowStatic(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(picker.OptionsList.MaxHeight, Is.EqualTo(PocDensity.OptionListMaxHeight), + "the list caps at the density token (~320) so long lists scroll"); + Assert.That(picker.OptionsList.GetVisualDescendants().OfType().Any(), + Is.True, "the items panel is a VirtualizingStackPanel — the ~1800-node semantic" + + " domain list must not realize every row"); + } + + [AvaloniaTest] + public void SearchBackedPicker_EnumeratesNothingUpFront_AndForwardsTheQuery() + { + var queries = new List(); + var lexicon = new List + { + new RegionChoiceOption("e-casa", "casa"), + new RegionChoiceOption("e-cantar", "cantar") + }; + var picker = new FwOptionPicker(null, + q => + { + queries.Add(q); + return lexicon.Where(o => o.Name.StartsWith(q, StringComparison.OrdinalIgnoreCase)).ToList(); + }, + "Components"); + var committed = new List(); + picker.OptionCommitted += committed.Add; + var window = new Window { Content = picker, Width = 400, Height = 420 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(picker.OptionsList.ItemsSource, Is.Null, + "lexicons search, lists enumerate: nothing materializes before the user types"); + + window.KeyTextInput("ca"); + Assert.That(queries, Does.Contain("ca"), "typing forwards the query to the host search"); + Assert.That(Items(picker).Select(o => o.Key), Is.EqualTo(new[] { "e-casa", "e-cantar" })); + + RaiseKey(picker.FilterBox, Key.Enter); + Assert.That(committed.Single().Key, Is.EqualTo("e-casa"), + "Enter commits the first search result by default"); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs index 8507eeb05f..5a9cb29762 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs @@ -22,12 +22,13 @@ namespace FwAvaloniaTests { /// - /// Hover-reveal chrome (UI polish): the chooser's settings gear and the reference vector's + /// Hover-reveal chrome (UI polish): the chooser's configure gear and the reference vector's /// separator bars + "+" launcher start hidden (opacity 0, not hit-testable, but still in /// layout and in the UIA tree), fade in while the pointer is over the row (label or editor), - /// and fade out when it leaves — driven here by REAL headless mouse input. Behavior is pinned - /// unchanged: the same flyouts open and the same staging calls fire once revealed, and the - /// row's layout height is identical hidden vs revealed (no reflow). + /// and fade out when it leaves — driven here by REAL headless mouse input. Gear semantics: + /// the gear renders only because these rows RESOLVE a list-editor target (chooserLinks), and + /// clicking it dispatches the jump directly — no flyout. The "+"/value click opens the + /// options picker, and staging still fires from it. /// [TestFixture] public class HoverRevealTests @@ -41,7 +42,11 @@ public class HoverRevealTests new RegionChoiceOption("g1", "stem"), new RegionChoiceOption("g2", "suffix") }, - "g1"); + "g1", + chooserLinks: new List + { + new RegionChooserLink("Edit the Morpheme Types list", "morphTypeEdit") + }); private static LexicalEditRegionField VectorField() => new LexicalEditRegionField( "LexEntry/x/#1", "Publish Entry In", "PublishIn", null, @@ -53,21 +58,27 @@ public class HoverRevealTests new RegionChoiceOption("p2", "Pocket") }, null, isEditable: true, indent: 0, - items: new List { new RegionChoiceOption("p1", "Main Dictionary") }); + items: new List { new RegionChoiceOption("p1", "Main Dictionary") }, + chooserLinks: new List + { + new RegionChooserLink("Edit the Publications list", "publicationsEdit") + }); - private static (LexicalEditRegionView view, FakeRegionEditContext context, Window window) Show() + private static (LexicalEditRegionView view, FakeRegionEditContext context, Window window, + List linkRequests) Show() { var model = new LexicalEditRegionModel("LexEntry", "test", new List { ChooserField(), VectorField() }, new List()); var context = new FakeRegionEditContext(); - var view = new LexicalEditRegionView(model, context); + var linkRequests = new List(); + var view = new LexicalEditRegionView(model, context, linkRequested: linkRequests.Add); var window = new Window { Content = view, Width = 500, Height = 300 }; window.Show(); Dispatcher.UIThread.RunJobs(); AvaloniaHeadlessPlatform.ForceRenderTimerTick(); Dispatcher.UIThread.RunJobs(); - return (view, context, window); + return (view, context, window, linkRequests); } private static T Find(Control view, string automationId) where T : Control @@ -121,7 +132,7 @@ private static void PumpUntilOpacity(Control control, double expected, string be [AvaloniaTest] public void Affordances_StartHidden_ByOpacity_StillInLayoutAndUiaTree() { - var (view, _, _) = Show(); + var (view, _, _, _) = Show(); var gear = Gear(view); Assert.That(gear, Is.Not.Null, "the gear is always in the tree (UIA/automation)"); @@ -151,7 +162,7 @@ public void Affordances_StartHidden_ByOpacity_StillInLayoutAndUiaTree() [AvaloniaTest] public void HoverOverChooser_RevealsGear_AndLeavingHidesItAgain() { - var (view, _, window) = Show(); + var (view, _, window, _) = Show(); var chooser = Find(view, "MorphTypeChooser"); var gear = Gear(view); @@ -167,7 +178,7 @@ public void HoverOverChooser_RevealsGear_AndLeavingHidesItAgain() [AvaloniaTest] public void HoverOverRowLabel_AlsoReveals_TheWholeRowIsTheHoverSurface() { - var (view, _, window) = Show(); + var (view, _, window, _) = Show(); var label = Find(view, "MorphTypeChooser.Label"); var gear = Gear(view); @@ -185,7 +196,7 @@ public void HoverOverRowLabel_AlsoReveals_TheWholeRowIsTheHoverSurface() [AvaloniaTest] public void HoverOverVectorRow_RevealsBarsAndPlus_AndLeavingHides() { - var (view, _, window) = Show(); + var (view, _, window, _) = Show(); var vector = Find(view, "PublishIn"); var affordances = VectorAffordances(view); @@ -203,7 +214,7 @@ public void HoverOverVectorRow_RevealsBarsAndPlus_AndLeavingHides() [AvaloniaTest] public void KeyboardFocus_OnAnAffordance_Reveals_AndLosingFocusHides() { - var (view, _, window) = Show(); + var (view, _, window, _) = Show(); var addButton = Find public sealed class FwMultiWsTextField : StackPanel, IHoverAffordanceProvider { - private readonly List _affordances = new List(); - public FwMultiWsTextField( LexicalEditRegionField field, string automationId, @@ -158,127 +155,75 @@ public FwMultiWsTextField( var rowPanel = new DockPanel { // 14.2: a null background only hit-tests the glyphs — the whole row must - // receive hover so the gear reveal works over the gaps. + // receive hover/right-click over the gaps too. Background = Brushes.Transparent }; DockPanel.SetDock(abbrev, Dock.Left); rowPanel.Children.Add(abbrev); - - // The legacy slice tree-node menu button (every slice has one; the layout's `menu=` - // names what it opens — mnuDataTree-LexemeForm on the Lexeme Form, mnuDataTree-Help - // elsewhere): a hover-revealed gear on the FIRST alternative's row that raises the - // same host menu bridge a label right-click does. - if (Children.Count == 0 && menuRequested != null && !string.IsNullOrEmpty(field.MenuId)) - { - var gearButton = RegionChrome.CreateGearButton(); - AutomationProperties.SetAutomationId(gearButton, automationId + ".Settings"); - AutomationProperties.SetName(gearButton, string.Format(FwAvaloniaStrings.FieldSettingsFormat, - field.Label ?? field.Field ?? automationId)); - gearButton.Click += (s2, e2) => - { - // The menu drops from the gear, like the legacy tree-node button's menu. - var screen = gearButton.PointToScreen(new Point(0, gearButton.Bounds.Height)); - menuRequested(new RegionMenuRequest(field, RegionMenuKind.SliceMenu, screen.X, screen.Y)); - }; - DockPanel.SetDock(gearButton, Dock.Right); - rowPanel.Children.Add(gearButton); - _affordances.Add(gearButton); - } - rowPanel.Children.Add(box); Children.Add(rowPanel); } - - // The gear hides until hover; the whole field panel is a hover source (the region view - // widens the surface to the row's label too, via IHoverAffordanceProvider). - if (_affordances.Count > 0) - HoverReveal.Attach(new Control[] { this }, _affordances); } - /// The slice-menu gear (rows with a legacy `menu=` binding); empty otherwise. - public IReadOnlyList HoverAffordances => _affordances; + /// Text rows have no hover-revealed chrome (the slice menu is right-click only). + public IReadOnlyList HoverAffordances => Array.Empty(); } /// - /// The list-editor jump links shared by the chooser and reference-vector gear flyouts (B7): - /// the legacy chooser dialog's "Edit the … list" LinkLabels (ReallySimpleListChooser.AddLink, - /// LinkType.kGotoLink), drawn below the options as link-styled items after a thin rule. - /// Clicking one closes the flyout and raises the host's - /// callback — the host dispatches the legacy mediator FollowLink jump. + /// GEAR = CONFIGURE: the shared gear semantics of the chooser and reference-vector rows. + /// Clicking the gear DIRECTLY dispatches the list-editor jump — the host's + /// callback rides the same lane the legacy chooser dialog's + /// "Edit the … list" LinkLabel rides (ReallySimpleListChooser.AddLink kGotoLink → + /// FollowLink). NO flyout, NO context menu opens from the gear; option flyouts carry zero + /// link items. The gear renders ONLY when a list-edit target resolved at compose time (the + /// row carries at least one goto ); the FIRST link wins when + /// several resolved (rare). Rows without a resolvable list editor draw no gear at all. /// - internal static class RegionLinkChrome + internal static class RegionGearChrome { /// - /// Returns unchanged when the row carries no links (or no - /// callback), else the options stacked over a separator rule and one link item per - /// . + /// Builds the configure gear for a row, or null when no list-edit target resolves + /// (no links on the row, or no host callback to dispatch through). /// - internal static Control WithChooserLinks(Control optionsContent, LexicalEditRegionField field, - string automationId, Action linkRequested, Flyout flyout) + internal static Button CreateConfigureGear(LexicalEditRegionField field, string automationId, + Action linkRequested) { if (linkRequested == null || field.ChooserLinks.Count == 0) - return optionsContent; - - var panel = new StackPanel { Spacing = 2 }; - panel.Children.Add(optionsContent); - panel.Children.Add(new Border - { - Height = 1, - Background = Brushes.LightGray, - Margin = new Thickness(0, 4, 0, 2) - }); + return null; - for (var i = 0; i < field.ChooserLinks.Count; i++) + var link = field.ChooserLinks[0]; // first goto wins + var gear = RegionChrome.CreateGearButton(); + AutomationProperties.SetAutomationId(gear, automationId + ".Settings"); + AutomationProperties.SetName(gear, string.Format(FwAvaloniaStrings.FieldSettingsFormat, + field.Label ?? field.Field ?? automationId)); + ToolTip.SetTip(gear, link.Label); + gear.Click += (s, e) => { - var link = field.ChooserLinks[i]; - var item = new Button - { - Content = new TextBlock - { - Text = link.Label, - Foreground = Brushes.RoyalBlue, - TextDecorations = TextDecorations.Underline - }, - Background = Brushes.Transparent, - BorderThickness = new Thickness(0), - Padding = new Thickness(4, 2, 4, 2), - MinHeight = 0, - HorizontalAlignment = HorizontalAlignment.Left, - Cursor = new Cursor(StandardCursorType.Hand) - }; - AutomationProperties.SetAutomationId(item, automationId + ".Link." + i); - AutomationProperties.SetName(item, link.Label ?? string.Empty); - item.Click += (s, e) => - { - // Legacy order: the chooser dialog closes (Cancel) and THEN the jump posts - // (m_lblLink1_LinkClicked → HandleAnyJump); here the flyout hides, then the - // host callback dispatches FollowLink. - flyout?.Hide(); - linkRequested(new RegionLinkRequest(field, link)); - }; - panel.Children.Add(item); - } - - return panel; + linkRequested(new RegionLinkRequest(field, link)); + e.Handled = true; + }; + return gear; } } /// /// FieldWorks-owned chooser field (task 6.3): a button opening a flyout of service-backed options - /// (the options come from the LCModel-sourced region model, not the control). Selecting an option - /// stages it through the edit context, closes the flyout, and returns focus to the button — the - /// popup-focus-return behavior the seam specs require. Without an edit context the chooser is a - /// read-only display of the current selection. - /// Chrome (hover-reveal polish): the button is transparent/borderless — the value text reads - /// flat like the legacy combo — and a settings-gear icon fades in on row hover (the modern - /// "this value has a supporting list" affordance). Clicking anywhere on the value still opens - /// the same flyout; staging and automation ids are unchanged. + /// (the options come from the LCModel-sourced region model, not the control). The flyout is the + /// shared compact — OPTIONS ONLY, no link items. Committing an + /// option stages it through the edit context, closes the flyout, and returns focus to the button + /// — the popup-focus-return behavior the seam specs require. Without an edit context the chooser + /// is a read-only display of the current selection. + /// Chrome: the button is transparent/borderless — the value text reads flat like the legacy + /// combo. When the row's supporting list resolved a list-editor target (a composed goto + /// ), a hover-revealed CONFIGURE gear sits after the value and + /// directly dispatches the host jump () — it never opens the + /// options. Rows without a resolvable list editor draw no gear. /// public sealed class FwChooserField : Button, IHoverAffordanceProvider { private string _selectedKey; private readonly TextBlock _valueText; - private readonly Control _gear; + private readonly Button _gear; public FwChooserField( LexicalEditRegionField field, @@ -297,13 +242,19 @@ public FwChooserField( Text = CurrentName(field), VerticalAlignment = VerticalAlignment.Center }; - _gear = CreateGear(automationId, field.Label ?? field.Field ?? automationId); - Content = new StackPanel + var content = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 6, - Children = { _valueText, _gear } + Children = { _valueText } }; + // GEAR = CONFIGURE: only a resolved list-edit target draws the gear; clicking it + // dispatches the jump directly (a nested Button handles its own click, so the + // chooser's flyout does NOT open from a gear click). + _gear = RegionGearChrome.CreateConfigureGear(field, automationId, linkRequested); + if (_gear != null) + content.Children.Add(_gear); + Content = content; IsEnabled = editContext != null && field.IsEditable; AutomationProperties.SetAutomationId(this, automationId); AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); @@ -315,31 +266,20 @@ public FwChooserField( if (editContext == null || !field.IsEditable) return; - var list = new ListBox + // "+"/chooser click = OPTIONS ONLY: the one compact filterable picker, zero links. + var picker = new FwOptionPicker(field.Options, null, automationId); + var flyout = new Flyout { - ItemsSource = field.Options.Select(o => o.Name).ToList(), - MinWidth = 120 + Placement = PlacementMode.BottomEdgeAlignedLeft, + Content = picker }; - AutomationProperties.SetAutomationId(list, automationId + ".Options"); - - var flyout = new Flyout { Placement = PlacementMode.BottomEdgeAlignedLeft }; - // B7: the layout's chooserLink jump links render below the options, like the legacy - // chooser dialog's link labels; without links the content stays the bare options list. - flyout.Content = RegionLinkChrome.WithChooserLinks(list, field, automationId, linkRequested, flyout); Flyout = flyout; - list.SelectionChanged += (s, e) => + picker.OptionCommitted += option => { - // The list items were materialized in field.Options order, so the selected INDEX is - // the only safe way back to the option: display names may repeat across options. - var index = list.SelectedIndex; - if (index < 0 || index >= field.Options.Count) - return; - var option = field.Options[index]; - if (option.Key == _selectedKey) - return; - - if (editContext.TrySetOption(field, option.Key)) + // The options are the field's own RegionChoiceOption instances, so the committed + // option's key is exact even when display names repeat across options. + if (option.Key != _selectedKey && editContext.TrySetOption(field, option.Key)) { _selectedKey = option.Key; _valueText.Text = option.Name; @@ -348,6 +288,11 @@ public FwChooserField( flyout.Hide(); Focus(); // popup focus return: back to the launcher }; + picker.Dismissed += (s, e) => + { + flyout.Hide(); + Focus(); + }; } // Restyled chrome only — the control keeps the Button theme (template, flyout-on-click, @@ -360,35 +305,30 @@ public FwChooserField( /// The display text of the current selection (what the value TextBlock shows). public string ValueText => _valueText.Text; - /// The settings gear is the chooser's only hover-revealed affordance. - public IReadOnlyList HoverAffordances => new[] { _gear }; + /// The configure gear (only when a list-edit target resolved); empty otherwise. + public IReadOnlyList HoverAffordances + => _gear == null ? Array.Empty() : new Control[] { _gear }; private static string CurrentName(LexicalEditRegionField field) { var selected = field.Options.FirstOrDefault(o => o.Key == field.SelectedOptionKey); return selected?.Name ?? string.Empty; } - - // The shared gear icon (RegionChrome) with this row's automation identity. - private static Control CreateGear(string automationId, string label) - { - var gear = RegionChrome.CreateGearIcon(); - AutomationProperties.SetAutomationId(gear, automationId + ".Settings"); - AutomationProperties.SetName(gear, string.Format(FwAvaloniaStrings.FieldSettingsFormat, label)); - return gear; - } } /// /// FieldWorks-owned editable reference-vector field (6.3/B8): the current items rendered /// inline, each followed by the thin grey separator bar legacy reference slices draw /// (VwSeparatorBox), with the TRAILING bar fronting the add slot — a "+" launcher whose flyout - /// lists the possibility tree indented by (the legacy - /// chooser tree; virtualized ListBox so the ~1800-node semantic-domain list stays usable). + /// is the shared compact (OPTIONS ONLY, zero link items): the + /// possibility tree indented by for enumerated lists, + /// or the host search delegate's results for search-backed vectors (lexicons search, lists + /// enumerate — D3), both behind the same filter box and virtualized capped list. /// Right-clicking an item offers Remove. Without an edit context the row is read-only display. - /// Chrome (hover-reveal polish): the separator bars, the "+" launcher, and the settings gear - /// (which opens the SAME flyout as the "+") fade in on row hover only — items/text stay always - /// visible; flyout, staging, and automation ids are unchanged. + /// Chrome (hover-reveal polish): the separator bars, the "+" launcher, and — only when the + /// row's list resolved a list-editor target — the CONFIGURE gear (which directly dispatches + /// the host jump, never a flyout: ) fade in on row hover; the + /// items/text stay always visible. /// public sealed class FwReferenceVectorField : StackPanel, IHoverAffordanceProvider { @@ -469,81 +409,48 @@ public FwReferenceVectorField( AutomationProperties.SetAutomationId(addButton, automationId + ".Add"); AutomationProperties.SetName(addButton, FwAvaloniaStrings.AddItem); - var list = new ListBox + // "+" = OPTIONS ONLY: the one compact filterable picker — static options enumerate + // (with Depth hierarchy), search-backed vectors ride the host search delegate (D3). + // No link items ever ride this flyout. + var picker = new FwOptionPicker(field.Options, field.SearchOptions, automationId); + var flyout = new Flyout { - ItemsSource = field.SearchOptions == null ? field.Options : null, - MaxHeight = 320, - MinWidth = 180, - ItemTemplate = new Avalonia.Controls.Templates.FuncDataTemplate( - (option, _) => option == null - ? null - : new TextBlock - { - Text = option.Name, - Margin = new Thickness(option.Depth * 14, 0, 0, 0) - }) + Placement = PlacementMode.BottomEdgeAlignedLeft, + Content = picker }; - AutomationProperties.SetAutomationId(list, automationId + ".Options"); - - // D3 (winforms-free-lexeme-editor.md): a search-backed vector (lexicons search, lists - // enumerate) fronts the same virtualized results list with a type-ahead search box — - // the whole lexicon is never materialized as options. - Control flyoutContent = list; - if (field.SearchOptions != null) - { - var searchBox = new TextBox - { - Watermark = FwAvaloniaStrings.SearchPrompt, - MinWidth = 180 - }; - AutomationProperties.SetAutomationId(searchBox, automationId + ".Search"); - AutomationProperties.SetName(searchBox, FwAvaloniaStrings.SearchPrompt); - var search = field.SearchOptions; - searchBox.TextChanged += (s, e) => - list.ItemsSource = search(searchBox.Text ?? string.Empty); - flyoutContent = new StackPanel - { - Spacing = PocDensity.RowSpacing, - Children = { searchBox, list } - }; - } - - var flyout = new Flyout { Placement = PlacementMode.BottomEdgeAlignedLeft }; - // B7: the layout's chooserLink jump links render below the options/search, like the - // legacy chooser dialog's link labels. - flyout.Content = RegionLinkChrome.WithChooserLinks(flyoutContent, field, automationId, - linkRequested, flyout); addButton.Flyout = flyout; - list.SelectionChanged += (s, e) => + picker.OptionCommitted += option => { - var added = list.SelectedItem is RegionChoiceOption option - && editContext.TryAddReferenceItem(field, option.Key); + var added = editContext.TryAddReferenceItem(field, option.Key); flyout.Hide(); - list.SelectedItem = null; addButton.Focus(); // popup focus return, like the chooser // Only a successful stage completes the gesture (commit + host re-show). if (added) gestureCompleted?.Invoke(); }; + picker.Dismissed += (s, e) => + { + flyout.Hide(); + addButton.Focus(); + }; Children.Add(addButton); _affordances.Add(addButton); - // The hover-revealed settings gear (the "this value has a supporting list" affordance, - // identical to the chooser's): it opens the SAME options/add flyout as the "+". - var gearButton = RegionChrome.CreateGearButton(); - gearButton.Flyout = flyout; - AutomationProperties.SetAutomationId(gearButton, automationId + ".Settings"); - AutomationProperties.SetName(gearButton, string.Format(FwAvaloniaStrings.FieldSettingsFormat, - field.Label ?? field.Field ?? automationId)); - Children.Add(gearButton); - _affordances.Add(gearButton); + // GEAR = CONFIGURE (only when the row's list resolved a list-editor target): clicking + // dispatches the host jump directly — it does NOT open the add flyout. + var gearButton = RegionGearChrome.CreateConfigureGear(field, automationId, linkRequested); + if (gearButton != null) + { + Children.Add(gearButton); + _affordances.Add(gearButton); + } // Bars, launcher, and gear hide until hover; the whole field panel is a hover source // (the region view widens the surface to the row's label too). Items stay always visible. HoverReveal.Attach(new Control[] { this }, _affordances); } - /// The separator bars, "+" launcher, and gear reveal on row hover (chrome only). + /// The separator bars, "+" launcher, and configure gear reveal on row hover. public IReadOnlyList HoverAffordances => _affordances; // The legacy VwSeparatorBox: a ~2px, font-height, light grey vertical bar after each item diff --git a/Src/Common/FwAvalonia/Region/FwOptionPicker.cs b/Src/Common/FwAvalonia/Region/FwOptionPicker.cs new file mode 100644 index 0000000000..a280a339a1 --- /dev/null +++ b/Src/Common/FwAvalonia/Region/FwOptionPicker.cs @@ -0,0 +1,232 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using Avalonia.Input; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.Styling; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace SIL.FieldWorks.Common.FwAvalonia.Region +{ + /// + /// The ONE compact, filterable option picker every option dropdown uses (the chooser's + /// single-select flyout and the reference vector's "+" add flyout, both the static-options + /// and the search-backed variants). Visually a selection panel, NOT a menu: a light border + /// with a filter box on top (auto-focused when the flyout opens, the ksSearchPrompt + /// watermark) over a VIRTUALIZED scrollable list capped at + /// , item density mirroring the legacy WinForms + /// menu spacing (, not the Fluent defaults), and + /// list hierarchy preserved through indentation. + /// + /// Typing filters live: static options filter case-insensitively by contains; a search-backed + /// picker (a non-null search delegate) forwards the query to the host's search instead + /// (lexicons search, lists enumerate — winforms-free-lexeme-editor.md D3). Keyboard: + /// Down/Up move the highlight, Enter commits the highlighted option (the first match by + /// default), Escape raises ; clicking an option commits it too. + /// Committing/dismissing is the HOST field's signal to stage (TrySetOption / + /// TryAddReferenceItem) and hide its flyout — the picker itself never stages. + /// + public sealed class FwOptionPicker : Border + { + private readonly IReadOnlyList _options; + private readonly Func> _searchOptions; + + /// The static option set (ignored when is supplied). + /// The host search delegate of a search-backed picker (D3), or null. + /// Row automation id; the picker's parts suffix ".Search"/".Options". + public FwOptionPicker(IReadOnlyList options, + Func> searchOptions, + string automationId) + { + _options = options ?? Array.Empty(); + _searchOptions = searchOptions; + + // A selection-filter panel, not a menu: light border + a hint of elevation. + Background = Brushes.White; + BorderBrush = Brushes.LightGray; + BorderThickness = new Thickness(1); + CornerRadius = new CornerRadius(3); + Padding = new Thickness(4); + AutomationProperties.SetAutomationId(this, automationId + ".Picker"); + + FilterBox = new TextBox + { + Watermark = FwAvaloniaStrings.SearchPrompt, + MinWidth = 180, + MinHeight = 0, + Padding = PocDensity.EditorPadding + }; + AutomationProperties.SetAutomationId(FilterBox, automationId + ".Search"); + AutomationProperties.SetName(FilterBox, FwAvaloniaStrings.SearchPrompt); + + OptionsList = new ListBox + { + MaxHeight = PocDensity.OptionListMaxHeight, + MinWidth = 180, + // Virtualization pinned explicitly (the ~1800-node semantic-domain list must not + // realize every row), independent of the theme's default panel. + ItemsPanel = new FuncTemplate(() => new VirtualizingStackPanel()), + ItemTemplate = new FuncDataTemplate( + (option, _) => option == null + ? null + : new TextBlock + { + Text = option.Name, + // B8 hierarchy: the legacy chooser tree's indent per nesting level. + Margin = new Thickness(option.Depth * 14, 0, 0, 0) + }), + // Legacy WinForms menu density: compact rows, not the Fluent container defaults. + ItemContainerTheme = CompactItemTheme() + }; + AutomationProperties.SetAutomationId(OptionsList, automationId + ".Options"); + + // Static options enumerate up front; a search-backed picker shows nothing until the + // user types (lexicons search, lists enumerate). + if (_searchOptions == null) + SetItems(_options); + + FilterBox.TextChanged += (s, e) => ApplyFilter(FilterBox.Text ?? string.Empty); + FilterBox.AddHandler(InputElement.KeyDownEvent, OnFilterKeyDown, + Avalonia.Interactivity.RoutingStrategies.Tunnel); + OptionsList.AddHandler(InputElement.KeyDownEvent, OnListKeyDown, + Avalonia.Interactivity.RoutingStrategies.Tunnel); + // Click commits: selection lands on pointer press; the release completes the gesture. + OptionsList.AddHandler(InputElement.PointerReleasedEvent, + (s, e) => CommitHighlighted(), + Avalonia.Interactivity.RoutingStrategies.Bubble); + + Child = new StackPanel + { + Spacing = PocDensity.RowSpacing, + Children = { FilterBox, OptionsList } + }; + + // The flyout attaches the picker when it opens: focus lands in the filter box so the + // user can type immediately. Posted at Loaded priority — focusing synchronously + // during the attach walk is too early (the control is not effectively visible yet). + AttachedToVisualTree += (s, e) => + Avalonia.Threading.Dispatcher.UIThread.Post(() => FilterBox.Focus(), + Avalonia.Threading.DispatcherPriority.Loaded); + } + + /// The type-to-filter box on top of the list (auto-focused on open). + public TextBox FilterBox { get; } + + /// The virtualized, height-capped option list under the filter box. + public ListBox OptionsList { get; } + + /// Raised when the user commits an option (Enter or click). + public event Action OptionCommitted; + + /// Raised when the user dismisses the picker (Escape); the host hides its flyout. + public event EventHandler Dismissed; + + /// Commits the highlighted option (the first item when none is highlighted yet). + public void CommitHighlighted() + { + var option = OptionsList.SelectedItem as RegionChoiceOption + ?? (OptionsList.ItemsSource as IEnumerable)?.FirstOrDefault(); + if (option != null) + OptionCommitted?.Invoke(option); + } + + private void ApplyFilter(string query) + { + if (_searchOptions != null) + { + // D3: the search-backed variant forwards the query to the host search delegate. + SetItems(_searchOptions(query)); + return; + } + + SetItems(string.IsNullOrEmpty(query) + ? _options + : _options.Where(o => (o.Name ?? string.Empty) + .IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToList()); + } + + // The first match is highlighted by default, so Enter commits it without arrowing down. + private void SetItems(IReadOnlyList items) + { + OptionsList.ItemsSource = items; + OptionsList.SelectedIndex = items != null && items.Count > 0 ? 0 : -1; + } + + private void OnFilterKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Down: + MoveHighlight(1); + e.Handled = true; + break; + case Key.Up: + MoveHighlight(-1); + e.Handled = true; + break; + case Key.Enter: + CommitHighlighted(); + e.Handled = true; + break; + case Key.Escape: + Dismissed?.Invoke(this, EventArgs.Empty); + e.Handled = true; + break; + } + } + + private void OnListKeyDown(object sender, KeyEventArgs e) + { + switch (e.Key) + { + case Key.Enter: + CommitHighlighted(); + e.Handled = true; + break; + case Key.Escape: + Dismissed?.Invoke(this, EventArgs.Empty); + e.Handled = true; + break; + } + } + + private void MoveHighlight(int delta) + { + var count = (OptionsList.ItemsSource as IEnumerable)?.Count() ?? 0; + if (count == 0) + return; + var next = OptionsList.SelectedIndex + delta; + OptionsList.SelectedIndex = Math.Max(0, Math.Min(count - 1, next)); + if (OptionsList.SelectedItem != null) + OptionsList.ScrollIntoView(OptionsList.SelectedItem); + } + + // One ControlTheme per picker: compact item padding/height that mirrors the legacy + // WinForms menu spacing — explicit values, never the Fluent theme defaults. Based on the + // app theme's own ListBoxItem theme so the container template (presenter, selection + // visuals) is preserved; only the density setters override. + private static ControlTheme CompactItemTheme() + { + ControlTheme baseTheme = null; + if (Application.Current != null + && Application.Current.TryGetResource(typeof(ListBoxItem), null, out var found)) + { + baseTheme = found as ControlTheme; + } + + var theme = new ControlTheme(typeof(ListBoxItem)) { BasedOn = baseTheme }; + theme.Setters.Add(new Setter(ListBoxItem.PaddingProperty, PocDensity.OptionItemPadding)); + theme.Setters.Add(new Setter(ListBoxItem.MinHeightProperty, 0d)); + return theme; + } + } +} diff --git a/Src/Common/FwAvalonia/Region/RegionMenuFlyout.cs b/Src/Common/FwAvalonia/Region/RegionMenuFlyout.cs index e900fbe867..913d1179db 100644 --- a/Src/Common/FwAvalonia/Region/RegionMenuFlyout.cs +++ b/Src/Common/FwAvalonia/Region/RegionMenuFlyout.cs @@ -46,7 +46,10 @@ private RegionMenuItem() /// /// Renders host-built trees as a native Avalonia /// (15.1) — the same items, enablement, checkmarks, and submenus the - /// legacy WinForms adapter menu shows, with Avalonia chrome. + /// legacy WinForms adapter menu shows, with Avalonia chrome. Density: every item carries the + /// explicit compact padding/height of the legacy WinForms menus + /// (/, + /// not the Fluent theme defaults); long menus keep the presenter's scrolling. /// public static class RegionMenuFlyout { @@ -80,7 +83,11 @@ private static IEnumerable BuildControls(IReadOnlyList var menuItem = new MenuItem { Header = item.Label, - IsEnabled = item.IsEnabled + IsEnabled = item.IsEnabled, + // Legacy WinForms menu density, pinned explicitly (the Fluent defaults pad + // context menus far taller than the legacy adapter menu). + Padding = Poc.PocDensity.MenuItemPadding, + MinHeight = Poc.PocDensity.MenuItemMinHeight }; if (item.IsChecked) menuItem.Icon = new TextBlock { Text = "✓" }; diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs index 9e362fce79..cebac11d43 100644 --- a/Src/xWorks/FullEntryRegionComposer.cs +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -139,6 +139,95 @@ void Add(ICmPossibility possibility, int depth) return options; } + // The legacy generic possibility-list → lists-area-tool derivation, mirrored statically. + // Research (gear = configure): when a legacy jump's target object is owned by a + // CmPossibilityList, LinkListener.FollowActiveLink (Src/xWorks/LinkListener.cs:507-517) + // publishes "GetToolForList", handled by AreaListener.GetToolForList + // (Src/LexText/LexTextDll/AreaListener.cs:388-418): it walks the lists-area tools in the + // window configuration, resolves each tool's clerk recordList (owner=/property=) to the + // actual list through the SDA, and returns the first tool whose clerk edits that list; + // unmatched (ownerless = user custom) lists derive Name-without-spaces + "Edit" + // (AreaListener.GetCustomListToolName, AreaListener.cs:832-835 — the tool name the lists + // area generates dynamically per custom list, AreaListener.CreateCustomToolNode). + // The composer runs without a window configuration, so the clerk table itself + // (DistFiles/Language Explorer/Configuration/Lists/areaConfiguration.xml clerks ↔ + // Lists/Edit/toolConfiguration.xml tools) is mirrored here, keyed (owner class, owning + // field name). Owned lists missing from the table have no lists-area editor → null → no + // gear on rows backed by them. + private static readonly IReadOnlyDictionary<(string Owner, string Field), string> ListEditorToolByOwnerField = + new Dictionary<(string, string), string> + { + { ("LangProject", "AffixCategories"), "affixCategoryEdit" }, + { ("LangProject", "AnnotationDefs"), "annotationDefEdit" }, + { ("LangProject", "AnthroList"), "anthroEdit" }, + { ("LangProject", "ConfidenceLevels"), "confidenceEdit" }, + { ("LangProject", "Education"), "educationEdit" }, + { ("LangProject", "GenreList"), "genresEdit" }, + { ("LangProject", "Locations"), "locationsEdit" }, + { ("LangProject", "People"), "peopleEdit" }, + { ("LangProject", "Positions"), "positionsEdit" }, + { ("LangProject", "Restrictions"), "restrictionsEdit" }, + { ("LangProject", "Roles"), "roleEdit" }, + { ("LangProject", "SemanticDomainList"), "semanticDomainEdit" }, + { ("LangProject", "Status"), "statusEdit" }, + { ("LangProject", "TextMarkupTags"), "textMarkupTagsEdit" }, + { ("LangProject", "TimeOfDay"), "timeOfDayEdit" }, + { ("LangProject", "TranslationTags"), "translationTypeEdit" }, + { ("LexDb", "ComplexEntryTypes"), "complexEntryTypeEdit" }, + { ("LexDb", "DialectLabels"), "dialectsListEdit" }, + { ("LexDb", "DomainTypes"), "domainTypeEdit" }, + { ("LexDb", "ExtendedNoteTypes"), "extNoteTypeEdit" }, + { ("LexDb", "Languages"), "languagesListEdit" }, + { ("LexDb", "MorphTypes"), "morphTypeEdit" }, + { ("LexDb", "PublicationTypes"), "publicationsEdit" }, + { ("LexDb", "References"), "lexRefEdit" }, + { ("LexDb", "SenseTypes"), "senseTypeEdit" }, + { ("LexDb", "Status"), "senseStatusEdit" }, + { ("LexDb", "UsageTypes"), "usageTypeEdit" }, + { ("LexDb", "VariantEntryTypes"), "variantEntryTypeEdit" }, + { ("DsDiscourseData", "ChartMarkers"), "chartmarkEdit" }, + { ("DsDiscourseData", "ConstChartTempl"), "charttempEdit" }, + { ("RnResearchNbk", "RecTypes"), "recTypeEdit" } + }; + + /// + /// Resolves the lists-area tool that edits — the configure gear's + /// jump target when the layout authored no explicit chooserLink. Mirrors legacy + /// AreaListener.GetToolForList: shipped lists match the lists-area clerk table by + /// (owner class, owning field); ownerless lists are user custom lists, whose dynamically + /// generated tool is Name-without-spaces + "Edit"; anything else resolves to null (no + /// lists-area editor exists, so the row gets no gear). + /// + internal static string ResolveListEditorTool(ICmPossibilityList list) + { + if (list == null) + return null; + + if (list.Owner == null) + { + // Legacy AreaListener.GetCustomListToolName (custom lists are ownerless). + var name = list.Name?.BestAnalysisAlternative?.Text; + return string.IsNullOrEmpty(name) || name == "***" + ? null + : name.Replace(" ", string.Empty) + "Edit"; + } + + string fieldName; + try + { + var mdc = (IFwMetaDataCacheManaged)list.Cache.DomainDataByFlid.MetaDataCache; + fieldName = mdc.GetFieldName(list.OwningFlid); + } + catch (Exception) + { + return null; + } + + return ListEditorToolByOwnerField.TryGetValue((list.Owner.ClassName, fieldName), out var tool) + ? tool + : null; + } + private sealed class ComposeState { private readonly LcmCache _cache; @@ -486,19 +575,22 @@ private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) null, null, menuId: "mnuDataTree-Help"); } - // B7: project the node's imported chooserLink metadata onto the row — the legacy - // chooser dialog's "Edit the … list" jump links (ReallySimpleListChooser. - // InitializeExtras, ReallySimpleListChooser.cs:887-926). Only the "goto" kind is - // implemented: it is the ONLY kind the lexeme-editor layouts use (all 95 shipped - // chooserLinks are type="goto"); legacy "dialog"/"simple" links need ChooserCommand - // lanes and are logged + skipped, never half-dispatched. The target guid stays empty - // like legacy m_guidLink (no lexeme-editor chooserInfo sets flidTextParam); labels - // localize through the same StringTable lane as XmlUtils.GetLocalizedAttributeValue. - private IReadOnlyList BuildChooserLinks(ViewNode node) + // B7: project the row's list-editor jump (the configure gear's direct dispatch target). + // The node's imported chooserLink metadata wins — the legacy chooser dialog's "Edit + // the … list" jump links (ReallySimpleListChooser.InitializeExtras, + // ReallySimpleListChooser.cs:887-926). Only the "goto" kind is implemented: it is the + // ONLY kind the lexeme-editor layouts use (all 95 shipped chooserLinks are + // type="goto"); legacy "dialog"/"simple" links need ChooserCommand lanes and are + // logged + skipped, never half-dispatched. The target guid stays empty like legacy + // m_guidLink (no lexeme-editor chooserInfo sets flidTextParam); labels localize + // through the same StringTable lane as XmlUtils.GetLocalizedAttributeValue. + // When the layout authored NO goto link but the row IS backed by a possibility list, + // the tool derives from the list the same way the legacy jump lane does (see + // ResolveListEditorTool); a list with no resolvable editor tool yields no link — and + // therefore NO gear on that row. + private IReadOnlyList BuildChooserLinks(ViewNode node, + ICmPossibilityList list = null) { - if (node.ChooserLinks.Count == 0) - return null; - List links = null; foreach (var link in node.ChooserLinks) { @@ -513,6 +605,21 @@ private IReadOnlyList BuildChooserLinks(ViewNode node) .Add(new RegionChooserLink(Localize(link.Label), link.Tool)); } + if (links == null && list != null) + { + var tool = ResolveListEditorTool(list); + if (tool != null) + { + var listName = list.Name?.BestAnalysisAlternative?.Text ?? string.Empty; + links = new List + { + new RegionChooserLink(string.Format(CultureInfo.CurrentCulture, + SIL.FieldWorks.Common.FwAvalonia.FwAvaloniaStrings.EditListFormat, + listName), tool) + }; + } + } + return links; } @@ -730,10 +837,10 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) return; } + var morphTypes = _cache.LangProject.LexDbOA?.MorphTypesOA; if (_morphTypeOptions == null) { _morphTypeOptions = new List(); - var morphTypes = _cache.LangProject.LexDbOA?.MorphTypesOA; if (morphTypes != null) { foreach (var possibility in morphTypes.ReallyReallyAllPossibilities.OfType() @@ -752,7 +859,7 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) node.LocalizationKey, node.Routing, null, options, form.MorphTypeRA?.Guid.ToString(), isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, objectHvo: obj.Hvo, - chooserLinks: BuildChooserLinks(node))); + chooserLinks: BuildChooserLinks(node, morphTypes))); OptionSetters[stableId] = optionKey => { @@ -822,7 +929,7 @@ private void AddAtomicPossibilityChooser(ViewNode node, ICmObject obj, int depth node.WritingSystem, RegionFieldKind.Chooser, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, options, selected, isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, - objectHvo: obj.Hvo, chooserLinks: BuildChooserLinks(node))); + objectHvo: obj.Hvo, chooserLinks: BuildChooserLinks(node, list))); var hvo = obj.Hvo; OptionSetters[stableId] = key => @@ -856,7 +963,7 @@ private void AddReferenceVector(ViewNode node, ICmObject obj, int depth, int fli node.AutomationId, node.LocalizationKey, node.Routing, null, options, null, isEditable: true, indent: depth, menuId: node.MenuId, contextMenuId: node.ContextMenuId, hotlinksId: node.HotlinksId, objectHvo: obj.Hvo, items: items, - chooserLinks: BuildChooserLinks(node))); + chooserLinks: BuildChooserLinks(node, list))); var hvo = obj.Hvo; ReferenceAddSetters[stableId] = key => diff --git a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs index be2e66ad61..84909bc5b7 100644 --- a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs +++ b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs @@ -126,6 +126,11 @@ private void EnsureLists() Cache.LangProject.LexDbOA.PublicationTypesOA.PossibilitiesOS.Add(mainDictionary); mainDictionary.Name.SetAnalysisDefaultWritingSystem("Main Dictionary"); } + + // Gear = configure: the morph-type chooser's list-editor jump is DERIVED from the + // list (its chooserInfo carries only a title), so the list itself must exist. + if (Cache.LangProject.LexDbOA.MorphTypesOA == null) + Cache.LangProject.LexDbOA.MorphTypesOA = listFactory.Create(); } private ComposedEntryRegion Compose(bool showHidden = false) @@ -317,17 +322,91 @@ public void Compose_PublishIn_CarriesThePublicationsJumpLink_WithEmptyTarget() "legacy m_guidLink stays Guid.Empty for this link — a plain tool jump"); } - // GAP 1 control: a chooserInfo without links (MorphologyParts.xml:280-283, title only) - // composes a chooser row with NO jump links — the link lane is data-driven, never invented. + // Gear = configure: a chooser whose layout authored NO chooserLink (MorphologyParts.xml's + // MorphTypeBasic chooserInfo is title-only) still resolves its list-editor jump by + // DERIVATION from the row's possibility list — LexDb.MorphTypes maps to the lists-area + // morphTypeEdit tool, exactly the legacy AreaListener.GetToolForList clerk-table walk + // (AreaListener.cs:388-418 over Lists/areaConfiguration.xml's MorphTypeList clerk + + // Lists/Edit/toolConfiguration.xml's morphTypeEdit tool). [Test] - public void Compose_MorphTypeChooser_HasNoJumpLinks() + public void Compose_MorphTypeChooser_DerivesTheMorphTypeEditJump_FromItsList() { var composed = Compose(); var morphType = composed.Model.Fields .Single(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser); - Assert.That(morphType.ChooserLinks, Is.Empty, - "MoForm-Detail-MorphTypeBasic's chooserInfo carries only a title, no chooserLink"); + Assert.That(morphType.ChooserLinks, Has.Count.EqualTo(1), + "no authored link, but the morph-type list resolves a lists-area editor"); + Assert.That(morphType.ChooserLinks[0].Tool, Is.EqualTo("morphTypeEdit")); + Assert.That(morphType.ChooserLinks[0].TargetGuid, Is.Null, "a plain tool jump"); + } + + // The derived lane never overrides an authored link, and the derivation itself mirrors + // the legacy clerk table: shipped lists by (owner class, owning field); ownerless custom + // lists by the dynamically generated Name-without-spaces + "Edit" tool + // (AreaListener.GetCustomListToolName); anything unmapped resolves to NO tool → no gear. + [Test] + public void ResolveListEditorTool_MirrorsTheLegacyListsAreaMapping() + { + Assert.That(FullEntryRegionComposer.ResolveListEditorTool( + Cache.LangProject.SemanticDomainListOA), Is.EqualTo("semanticDomainEdit")); + Assert.That(FullEntryRegionComposer.ResolveListEditorTool( + Cache.LangProject.StatusOA), Is.EqualTo("statusEdit")); + Assert.That(FullEntryRegionComposer.ResolveListEditorTool( + Cache.LangProject.LexDbOA.UsageTypesOA), Is.EqualTo("usageTypeEdit")); + Assert.That(FullEntryRegionComposer.ResolveListEditorTool( + Cache.LangProject.AnthroListOA), Is.EqualTo("anthroEdit")); + Assert.That(FullEntryRegionComposer.ResolveListEditorTool( + Cache.LangProject.LexDbOA.PublicationTypesOA), Is.EqualTo("publicationsEdit")); + Assert.That(FullEntryRegionComposer.ResolveListEditorTool(null), Is.Null); + } + + [Test] + public void ResolveListEditorTool_CustomOwnerlessList_DerivesTheGeneratedToolName() + { + ICmPossibilityList custom = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + custom = Cache.ServiceLocator.GetInstance() + .CreateUnowned("Bird Species", Cache.DefaultAnalWs); + }); + + // Legacy AreaListener.GetCustomListToolName: Name without whitespace + "Edit" — the + // tool name the lists area generates for the custom list's dynamic tool node. + Assert.That(FullEntryRegionComposer.ResolveListEditorTool(custom), + Is.EqualTo("BirdSpeciesEdit")); + } + + [Test] + public void ResolveListEditorTool_OwnedListOutsideTheListsArea_ResolvesNoTool() + { + // LangProject.CheckLists is a possibility-list home with NO lists-area tool (it is + // excluded even from translated-list export): no tool → the composer adds no link → + // the row draws no gear. + ICmPossibilityList checkList = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + checkList = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.CheckListsOC.Add(checkList); + }); + + Assert.That(FullEntryRegionComposer.ResolveListEditorTool(checkList), Is.Null, + "a list with no resolvable lists-area editor yields no jump (and no gear)"); + } + + // The authored-link-wins half: Publish In carries the layout's own chooserLink, so the + // derived lane must not add a second link (first goto wins at the gear). + [Test] + public void Compose_PublishIn_AuthoredLinkWins_NoDerivedDuplicate() + { + var composed = Compose(); + var publishIn = composed.Model.Fields.Single(f => f.Field == "PublishIn" + && f.Kind == RegionFieldKind.ReferenceVector && f.ObjectHvo == m_entry.Hvo); + + Assert.That(publishIn.ChooserLinks, Has.Count.EqualTo(1), + "the authored goto link rides alone — derivation only fills gaps"); + Assert.That(publishIn.ChooserLinks[0].Label, Is.EqualTo("Edit the Publications list"), + "the authored (localizable) label is kept, not the derived format"); } // B7: a chooserInfo guicontrol "...FlatList" spec means the legacy chooser presents the list diff --git a/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md index b76569d31d..2f6b1a241e 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md +++ b/openspec/changes/lexical-edit-avalonia-migration/winforms-free-lexeme-editor.md @@ -1,6 +1,7 @@ # WinForms-Free Lexeme Editor — Decisions and Burn-Down -Status: ACTIVE (decisions approved; waves landing behind tests) +Status: ACTIVE (decisions approved; waves landed; 2026-06-12 UX-semantics rework landed — +gear = configure-jump only, options-only `FwOptionPicker` flyouts, compact menu density) Owner lane: lexical-edit-avalonia-migration, follows B11 (dynamic editors) and the companion-strip coexistence lane recorded in `xml-retirement-blockers.md`. @@ -163,28 +164,42 @@ Status (wave 4, 2026-06-11): LANDED. media lane (6.12) like the native player. ### Post-wave gap fixes (user-reported, 2026-06-11): LANDED - -- **Chooser jump links (gear flyouts):** the legacy chooser dialog's "Edit the … list" - LinkLabels (`ReallySimpleListChooser.InitializeExtras`/`AddLink`, kGotoLink → - `PostMessage("FollowLink", new FwLinkArgs(tool, m_guidLink))`) are recreated end to end: - `` imports onto the typed node - (`ViewNode.ChooserLinks`, canonical-JSON `chooserLinks` block — B7's schema reservation), - the composer projects the "goto" links (the only kind the lexeme-editor layouts use; all 95 - shipped links are goto) onto chooser AND reference-vector rows - (`LexicalEditRegionField.ChooserLinks`), the gear/+ flyouts render them below the options - (`RegionLinkChrome`), and the click rides a `RegionLinkRequest` host callback that - `RecordEditView.OnRegionLinkRequested` dispatches as the identical legacy jump (settle, then - mediator `FollowLink` with `FwLinkArgs(tool, Guid.Empty)` — no lexeme-editor chooserInfo - sets `flidTextParam`). chooserInfo's other facets (title/text/guicontrol FlatList) remain - the measured B7 remainder. -- **Lexeme Form gear:** the legacy Lexeme Form slice's button is its slice TREE-NODE MENU - (`MoForm-Detail-AsLexemeForm` binds `menu="mnuDataTree-LexemeForm"`: Show in Concordance / - Swap with Allomorph / Convert to Affix Process/Allomorph) — NOT a chooser launcher (the - morph-type chooser with the `MorphTypeSwapLogic` gate is the child Morph Type row, which - already has its gear). Recreated data-driven: any text row whose layout carries a `menu=` - binding draws the same hover-revealed `RegionChrome` gear, and clicking it raises the SAME - slice-menu `RegionMenuRequest` a label right-click raises — the host shows the identical - xCore menu. Nothing is hardcoded to "LexemeForm". +### UX-semantics rework (user direction, 2026-06-11/12): LANDED + +- **Gear = CONFIGURE, never choose:** the gear on a chooser/reference-vector row DIRECTLY + dispatches the list-editor jump (`RegionGearChrome` → `RegionLinkRequest` → + `RecordEditView.OnRegionLinkRequested` → settle + mediator `FollowLink` with + `FwLinkArgs(tool, Guid.Empty)`); no flyout, no context menu ever opens from a gear. The + gear renders ONLY when a list-edit target resolves: (a) the node's imported + `` wins (B7's import lane — `ViewNode.ChooserLinks`, + canonical-JSON `chooserLinks` block; all 95 shipped links are goto; first goto wins when + several ride one row); (b) else the composer DERIVES the tool from the row's possibility + list, mirroring the legacy generic jump lane: `LinkListener.FollowActiveLink` + (Src/xWorks/LinkListener.cs:507-517) publishes `GetToolForList`, answered by + `AreaListener.GetToolForList` (Src/LexText/LexTextDll/AreaListener.cs:388-418) walking the + lists-area tools' clerks (`Lists/areaConfiguration.xml` recordList owner=/property= ↔ + `Lists/Edit/toolConfiguration.xml` tools); the composer mirrors that clerk table statically + (`FullEntryRegionComposer.ListEditorToolByOwnerField`, keyed (owner class, owning field) — + so morph type/semantic domains/usages/status gears work too) and derives ownerless custom + lists as Name-without-spaces + "Edit" (`AreaListener.GetCustomListToolName`). A list with + no resolvable editor tool → NO gear on that row. chooserInfo's other facets + (title/text/guicontrol FlatList) remain the measured B7 remainder. +- **"+" and single-select chooser click = OPTIONS ONLY:** option flyouts carry zero link + items (`RegionLinkChrome` deleted). Every option dropdown is the ONE compact filterable + picker `FwOptionPicker` (Region/FwOptionPicker.cs): a selection-filter panel (light + border), filter TextBox on top (auto-focused on open, ksSearchPrompt watermark) over a + VIRTUALIZED list capped at `PocDensity.OptionListMaxHeight` (320), item padding pinned to + the legacy WinForms menu density (`PocDensity.OptionItemPadding`, (6,2)), hierarchy via the + existing Depth indent. Typing filters live (case-insensitive contains; the search-backed + vector forwards the query to the D3 `SearchOptions` delegate); Down/Up move, Enter commits + the highlighted option (first match by default), Esc closes, click commits. Staging is + unchanged (`TrySetOption` / `TryAddReferenceItem`). +- **Lexeme Form gear: REVERTED.** Gears never open menus, so the text-row slice-menu gear + (the earlier gap fix) is gone; the slice menu (`menu="mnuDataTree-LexemeForm"` etc.) stays + on right-click only — the label/value right-click lanes are unchanged. +- **Context-menu density:** `RegionMenuFlyout` items pin the explicit compact legacy padding + (`PocDensity.MenuItemPadding`/`MenuItemMinHeight`), not the Fluent defaults; long menus + keep the presenter's scrolling. ### D5. Governance: the burn-down is enforced by tests, not intentions diff --git a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md index bdfb6a1e29..1df7f2b4e1 100644 --- a/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md +++ b/openspec/changes/lexical-edit-avalonia-migration/xml-retirement-blockers.md @@ -224,14 +224,20 @@ change's surfaces. fenced session, duplicate/garbage/unknown keys rejected without opening it), covered by `FullEntryRegionReferenceChooserTests`. `BuildPossibilityOptions(flat:)` implements the FlatList guicontrol semantics. -- **Status (jump links landed, 2026-06-11):** `chooserLink` now imports onto the typed node - (`ViewNode.ChooserLinks`: type/label/tool/target, the legacy reader's exact vocabulary; - canonical JSON reserves the `chooserLinks` block, closing B7's share of the cross-cutting - schema deadline) and threads through the composer onto chooser/reference-vector rows - (`LexicalEditRegionField.ChooserLinks`, "goto" only — all 95 shipped links are goto); the - gear flyouts render the links and `RecordEditView` dispatches the legacy jump (mediator - `FollowLink`, `FwLinkArgs(tool, Guid.Empty)` exactly like - `ReallySimpleListChooser.HandleAnyJump`). REMAINING: import `chooserInfo`'s OTHER facets +- **Status (jump links landed, 2026-06-11; gear semantics reworked, 2026-06-12):** + `chooserLink` imports onto the typed node (`ViewNode.ChooserLinks`: + type/label/tool/target, the legacy reader's exact vocabulary; canonical JSON reserves the + `chooserLinks` block, closing B7's share of the cross-cutting schema deadline) and threads + through the composer onto chooser/reference-vector rows + (`LexicalEditRegionField.ChooserLinks`, "goto" only — all 95 shipped links are goto). + Surfacing (reworked): the row's hover gear DIRECTLY dispatches the jump (gear = configure; + first goto wins; no link items in option flyouts), and rows whose layout authored no link + derive the tool from their possibility list via the mirrored legacy lists-area clerk table + (`FullEntryRegionComposer.ResolveListEditorTool`, the `AreaListener.GetToolForList` lane; + ownerless custom lists derive Name-without-spaces + "Edit"; unresolvable lists draw no + gear). `RecordEditView` dispatches the legacy jump (mediator `FollowLink`, + `FwLinkArgs(tool, Guid.Empty)` exactly like `ReallySimpleListChooser.HandleAnyJump`). + REMAINING: import `chooserInfo`'s OTHER facets (title/text/textparam/flidTextParam/guicontrol — still reported as `slice-content-dropped`) and thread the flat/title specs to the composer call sites (the composer currently passes `flat: false`). From 5b5f28263e9fba4a136f39d70cfb94a28972b286 Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 12 Jun 2026 08:18:32 -0400 Subject: [PATCH 08/14] Bug fixes and cleanup --- .github/prompts/opsx-apply.prompt.md | 148 +---- .github/prompts/opsx-archive.prompt.md | 156 +---- .github/prompts/opsx-bulk-archive.prompt.md | 237 +------- .github/prompts/opsx-continue.prompt.md | 110 +--- .github/prompts/opsx-explore.prompt.md | 169 +----- .github/prompts/opsx-ff.prompt.md | 93 +-- .github/prompts/opsx-new.prompt.md | 65 +- .github/prompts/opsx-onboard.prompt.md | 553 +----------------- .github/prompts/opsx-propose.prompt.md | 95 +-- .github/prompts/opsx-sync.prompt.md | 130 +--- .github/prompts/opsx-verify.prompt.md | 161 +---- .../DetailControls/MorphTypeAtomicLauncher.cs | 22 + Src/Common/FwAvalonia/FwAvaloniaStrings.cs | 9 + Src/Common/FwAvalonia/FwAvaloniaStrings.resx | 12 + .../FwAvaloniaTests/FwOptionPickerTests.cs | 61 ++ .../FwAvaloniaTests/HoverRevealTests.cs | 45 ++ .../FwAvalonia/FwAvaloniaTests/SeamTests.cs | 7 +- Src/Common/FwAvalonia/Poc/PocDensity.cs | 14 + .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 19 + .../FwAvalonia/Region/FwFieldControls.cs | 15 +- .../FwAvalonia/Region/FwOptionPicker.cs | 22 +- Src/Common/FwAvalonia/Region/HoverReveal.cs | 112 +++- .../Region/LexicalEditFirstSlice.cs | 8 +- .../Region/LexicalEditRegionMapper.cs | 22 +- .../Region/LexicalEditRegionView.cs | 10 +- .../FinalizerSafeSynchronizationContext.cs | 23 +- .../FwAvalonia/Seams/MorphTypeSwapLogic.cs | 49 ++ .../ViewDefinition/EditorKindMap.cs | 133 ++++- Src/xWorks/AvaloniaRegionRefreshController.cs | 36 +- Src/xWorks/ChorusNotesPlugin.cs | 140 +++-- Src/xWorks/DialogLauncherPlugins.cs | 21 +- Src/xWorks/FullEntryRegionComposer.cs | 393 +++++++++---- Src/xWorks/LegacyDialogLauncher.cs | 23 +- Src/xWorks/LexicalEditRegionBuilder.cs | 55 +- Src/xWorks/RecordEditView.cs | 205 +++++-- Src/xWorks/RegionEditContextHolder.cs | 30 + Src/xWorks/RegionEditorPlugins.cs | 51 +- Src/xWorks/RegionValueFactory.cs | 72 +++ Src/xWorks/XCoreMenuBridge.cs | 12 +- .../xWorksTests/ChorusNotesContractTests.cs | 2 +- .../xWorksTests/DialogLauncherPluginTests.cs | 54 +- .../FullEntryRegionReferenceChooserTests.cs | 45 +- .../xWorksTests/LexemeEditorBurnDownTests.cs | 14 +- .../LexicalEditRegionEditingTests.cs | 182 +++++- .../RecordEditViewActiveHostContractTests.cs | 14 + .../xWorksTests/RegionConsolidationTests.cs | 137 +++++ .../RegionEditGuardAndSchedulingTests.cs | 104 +++- 47 files changed, 1822 insertions(+), 2268 deletions(-) create mode 100644 Src/xWorks/RegionValueFactory.cs create mode 100644 Src/xWorks/xWorksTests/RegionConsolidationTests.cs diff --git a/.github/prompts/opsx-apply.prompt.md b/.github/prompts/opsx-apply.prompt.md index e23ec64d14..fedf7b8b88 100644 --- a/.github/prompts/opsx-apply.prompt.md +++ b/.github/prompts/opsx-apply.prompt.md @@ -1,149 +1,9 @@ --- description: Implement tasks from an OpenSpec change (Experimental) +agent: "agent" +argument-hint: "Optional change name" --- -Implement tasks from an OpenSpec change. +Use the `openspec-apply-change` skill from `.claude/skills/openspec-apply-change/SKILL.md`. -**Input**: Optionally specify a change name (e.g., `/opsx:apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes. - -**Steps** - -1. **Select the change** - - If a name is provided, use it. Otherwise: - - Infer from conversation context if the user mentioned a change - - Auto-select if only one active change exists - - If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select - - Always announce: "Using change: " and how to override (e.g., `/opsx:apply `). - -2. **Check status to understand the schema** - ```bash - openspec status --change "" --json - ``` - Parse the JSON to understand: - - `schemaName`: The workflow being used (e.g., "spec-driven") - - Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others) - -3. **Get apply instructions** - - ```bash - openspec instructions apply --change "" --json - ``` - - This returns: - - `contextFiles`: artifact ID -> array of concrete file paths (varies by schema) - - Progress (total, complete, remaining) - - Task list with status - - Dynamic instruction based on current state - - **Handle states:** - - If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx:continue` - - If `state: "all_done"`: congratulate, suggest archive - - Otherwise: proceed to implementation - -4. **Read context files** - - Read every file path listed under `contextFiles` from the apply instructions output. - The files depend on the schema being used: - - **spec-driven**: proposal, specs, design, tasks - - Other schemas: follow the contextFiles from CLI output - -5. **Show current progress** - - Display: - - Schema being used - - Progress: "N/M tasks complete" - - Remaining tasks overview - - Dynamic instruction from CLI - -6. **Implement tasks (loop until done or blocked)** - - For each pending task: - - Show which task is being worked on - - Make the code changes required - - Keep changes minimal and focused - - Mark task complete in the tasks file: `- [ ]` → `- [x]` - - Continue to next task - - **Pause if:** - - Task is unclear → ask for clarification - - Implementation reveals a design issue → suggest updating artifacts - - Error or blocker encountered → report and wait for guidance - - User interrupts - -7. **On completion or pause, show status** - - Display: - - Tasks completed this session - - Overall progress: "N/M tasks complete" - - If all done: suggest archive - - If paused: explain why and wait for guidance - -**Output During Implementation** - -``` -## Implementing: (schema: ) - -Working on task 3/7: -[...implementation happening...] -✓ Task complete - -Working on task 4/7: -[...implementation happening...] -✓ Task complete -``` - -**Output On Completion** - -``` -## Implementation Complete - -**Change:** -**Schema:** -**Progress:** 7/7 tasks complete ✓ - -### Completed This Session -- [x] Task 1 -- [x] Task 2 -... - -All tasks complete! You can archive this change with `/opsx:archive`. -``` - -**Output On Pause (Issue Encountered)** - -``` -## Implementation Paused - -**Change:** -**Schema:** -**Progress:** 4/7 tasks complete - -### Issue Encountered - - -**Options:** -1. public static string EditListFormat => Resources.GetString("ksEditListFormat"); + + /// "Lexeme Form" — first-slice row label (compiled override and authored fallback). + public static string LexemeFormLabel => Resources.GetString("ksLexemeFormLabel"); + + /// "Morph Type" — first-slice row label (authored fallback). + public static string MorphTypeLabel => Resources.GetString("ksMorphTypeLabel"); + + /// "Gloss" — first-slice row label (authored fallback). + public static string GlossLabel => Resources.GetString("ksGlossLabel"); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index bae44a7f70..956733b818 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -101,4 +101,16 @@ Edit the {0} list Tooltip/label of a configure gear whose list-editor jump was derived from the row's possibility list; {0} is the list name (mirrors the legacy chooser's "Edit the … list" links). + + Lexeme Form + Label of the lexeme-form row in the first-slice entry view (compiled label override and authored fallback). + + + Morph Type + Label of the morph-type row in the first-slice entry view (authored fallback). + + + Gloss + Label of the gloss row in the first-slice entry view (authored fallback). + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs index 115eb22904..92a1e316d7 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs @@ -7,6 +7,7 @@ using System.Linq; using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Controls.Primitives; using Avalonia.Headless; using Avalonia.Headless.NUnit; using Avalonia.Input; @@ -226,5 +227,65 @@ public void SearchBackedPicker_EnumeratesNothingUpFront_AndForwardsTheQuery() Assert.That(committed.Single().Key, Is.EqualTo("e-casa"), "Enter commits the first search result by default"); } + + [AvaloniaTest] + public void PointerRelease_OnTheListScrollbar_DoesNotCommit() + { + // Enough options to overflow the capped list, so the scrollbar is a real part of + // the gesture surface. + var options = Enumerable.Range(0, 60) + .Select(i => new RegionChoiceOption("k" + i, "Option " + i)) + .ToList(); + var picker = new FwOptionPicker(options, null, "Domains"); + var committed = new List(); + picker.OptionCommitted += committed.Add; + var window = new Window { Content = picker, Width = 400, Height = 420 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + var scrollBar = picker.OptionsList.GetVisualDescendants().OfType() + .FirstOrDefault(b => b.Orientation == Avalonia.Layout.Orientation.Vertical); + Assert.That(scrollBar, Is.Not.Null, "the list template carries a vertical scrollbar"); + + RaiseRelease(scrollBar, window); + + Assert.That(committed, Is.Empty, + "a release landing on the scrollbar (not an option row) must not commit the highlight"); + } + + [AvaloniaTest] + public void PointerRelease_OnAnOptionRow_StillCommits() + { + var (picker, window, committed, _) = ShowStatic(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + picker.OptionsList.SelectedIndex = 2; // the press selects; the release completes + var container = picker.OptionsList.ContainerFromIndex(2); + Assert.That(container, Is.Not.Null, "the option's container is realized"); + + RaiseRelease(container, window); + + Assert.That(committed.Select(o => o.Key), Is.EqualTo(new[] { "u-weather" }), + "a release that lands on an option row commits the highlighted option"); + } + + // A pointer release routed from a SPECIFIC template part: the commit guard keys off where + // the release landed (e.Source), which headless window clicks cannot steer onto the + // scrollbar deterministically. + private static void RaiseRelease(Control source, Window window) + { + source.RaiseEvent(new PointerReleasedEventArgs(source, + new Pointer(Pointer.GetNextFreeId(), PointerType.Mouse, true), + window, default, 0, + new PointerPointProperties(RawInputModifiers.None, + PointerUpdateKind.LeftButtonReleased), + KeyModifiers.None, MouseButton.Left)); + Dispatcher.UIThread.RunJobs(); + } } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs index 5a9cb29762..0996d1e47a 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/HoverRevealTests.cs @@ -12,6 +12,7 @@ using Avalonia.Controls; using Avalonia.Headless; using Avalonia.Headless.NUnit; +using Avalonia.Media; using Avalonia.Threading; using Avalonia.VisualTree; using NUnit.Framework; @@ -381,6 +382,50 @@ public void LauncherGear_WithoutAService_RevealsDisabled_WithTheExplanatoryToolt Assert.That(() => row.Launch(), Throws.Nothing, "launching without a callback is a no-op"); } + // Idempotence regression: controls attach their own affordances in their constructors and + // the region view attaches AGAIN to widen the hover surface to the row. Before the merge + // fix each Attach stacked an independent handler set with its own watched list, and the + // LAST registration could hide the affordance while the pointer was still over a source + // only an EARLIER registration watched (correctness depended on the superset attaching + // last). Attaching the SUBSET last here proves the registrations merge. + [AvaloniaTest] + public void Attach_MergesRepeatedRegistrations_RegardlessOfOrder() + { + var rowSurface = new Border { Width = 200, Height = 40, Background = Brushes.Transparent }; + var editor = new Border { Width = 200, Height = 40, Background = Brushes.Transparent }; + var gear = new Button { Content = "*", Width = 20, Height = 20 }; + var focusPark = new TextBox(); + var window = new Window + { + Content = new StackPanel { Children = { rowSurface, editor, gear, focusPark } }, + Width = 500, + Height = 300 + }; + HoverReveal.Attach(new Control[] { rowSurface, editor }, new Control[] { gear }); // superset first + HoverReveal.Attach(new Control[] { editor }, new Control[] { gear }); // subset last + window.Show(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + + // Hover the source only the FIRST registration named... + MoveMouseOver(window, rowSurface); + Assert.That(gear.IsHitTestVisible, Is.True, "a first-registration source still reveals"); + + // ...then a focus blip on the gear forces a LostFocus re-evaluation of the hover + // state. The merged registration still sees the pointer over rowSurface; stacked + // registrations fought, and the last one hid the gear (it never watched rowSurface). + gear.Focus(); + Dispatcher.UIThread.RunJobs(); + focusPark.Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(gear.IsHitTestVisible, Is.True, + "one merged watched list decides the state — not whichever Attach ran last"); + + MoveMouseFarAway(window); + Assert.That(gear.IsHitTestVisible, Is.False, "leaving every merged source still hides"); + } + [AvaloniaTest] public void RowLayoutHeight_DoesNotChange_BetweenHiddenAndRevealed() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs index 43a003f72d..4f9bcda547 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/SeamTests.cs @@ -12,7 +12,8 @@ namespace FwAvaloniaTests /// 16.1 — the crash guard for WinForms-hosted Avalonia: MicroCom proxy finalizers post their /// native Release through the captured SynchronizationContext; when the WinForms marshaling /// window is gone that post throws on the FINALIZER thread and terminates the process. The - /// wrapper swallows exactly those marshal failures and passes everything else through. + /// wrapper swallows exactly those marshal failures on POST (the finalizer lane) and passes + /// everything else through — synchronous Send failures still surface to the waiting caller. /// [TestFixture] public class FinalizerSafeSynchronizationContextTests @@ -42,7 +43,9 @@ public void Post_SwallowsDeadMarshalingTargetFailures_InsteadOfKillingTheProcess var guarded = new FinalizerSafeSynchronizationContext(new DeadTargetContext()); Assert.DoesNotThrow(() => guarded.Post(_ => { }, null), "the exact failure mode of MicroComProxyBase.Finalize must not propagate"); - Assert.DoesNotThrow(() => guarded.Send(_ => { }, null)); + Assert.Throws(() => guarded.Send(_ => { }, null), + "Send is synchronous caller work, not a finalizer Release — a silently skipped " + + "callback would corrupt the waiting caller, so the failure must surface"); } [Test] diff --git a/Src/Common/FwAvalonia/Poc/PocDensity.cs b/Src/Common/FwAvalonia/Poc/PocDensity.cs index 694000d12c..65ae4bdd3c 100644 --- a/Src/Common/FwAvalonia/Poc/PocDensity.cs +++ b/Src/Common/FwAvalonia/Poc/PocDensity.cs @@ -67,5 +67,19 @@ public static class PocDensity /// Compact context-menu item height floor (legacy items are ~22px, Fluent ~32px). public const double MenuItemMinHeight = 22.0; + + // ---- Centralized chrome brushes: no hardcoded colors at the use sites, one place to tune. + + /// The option picker panel surface (a light selection panel, not a menu). + public static readonly Avalonia.Media.IBrush PickerBackgroundBrush = Avalonia.Media.Brushes.White; + + /// The option picker panel border. + public static readonly Avalonia.Media.IBrush PickerBorderBrush = Avalonia.Media.Brushes.LightGray; + + /// Inline validation-error text in the region edit footer. + public static readonly Avalonia.Media.IBrush ValidationErrorBrush = Avalonia.Media.Brushes.Firebrick; + + /// The heavy 2px rule above top-level section headers (legacy heavy separator). + public static readonly Avalonia.Media.IBrush SectionRuleBrush = Avalonia.Media.Brushes.LightGray; } } diff --git a/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs index 310678cebd..3a36e92970 100644 --- a/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs +++ b/Src/Common/FwAvalonia/Poc/PocWinFormsHostControl.cs @@ -96,6 +96,25 @@ public void SetCompanionControls(System.Collections.Generic.IReadOnlyList UpdateCompanionStripHeight(); + /// + /// SetCompanionControls promises companion lifetime stays with the caller, but + /// disposes parented children — + /// so detach the companions (and their SizeChanged hooks) BEFORE base disposal runs. + /// + protected override void Dispose(bool disposing) + { + if (disposing && _companionStrip != null) + { + for (var i = _companionStrip.Controls.Count - 1; i >= 0; i--) + { + var companion = _companionStrip.Controls[i]; + companion.SizeChanged -= OnCompanionControlSizeChanged; + _companionStrip.Controls.RemoveAt(i); // never dispose: the caller owns it + } + } + base.Dispose(disposing); + } + // The strip auto-sizes to its stacked children and collapses (hidden, zero-height) when empty. private void UpdateCompanionStripHeight() { diff --git a/Src/Common/FwAvalonia/Region/FwFieldControls.cs b/Src/Common/FwAvalonia/Region/FwFieldControls.cs index dcd99de3b1..326042a925 100644 --- a/Src/Common/FwAvalonia/Region/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Region/FwFieldControls.cs @@ -132,15 +132,18 @@ public FwMultiWsTextField( if (editContext != null && field.IsEditable) { // TextChanged also fires when the template first applies the initial value, so a - // last-staged guard keeps construction and no-op events from staging. + // last-staged guard keeps construction and no-op events from staging. The guard + // only advances on a SUCCESSFUL stage: a failed TrySetText leaves lastStaged at + // the last text the domain actually received, so further edits (including retyping + // the same text) re-attempt instead of being suppressed forever. var lastStaged = value.Value ?? string.Empty; box.TextChanged += (s, e) => { var text = box.Text ?? string.Empty; if (text == lastStaged) return; - lastStaged = text; - editContext.TrySetText(field, wsKey, text); + if (editContext.TrySetText(field, wsKey, text)) + lastStaged = text; }; } @@ -255,7 +258,11 @@ public FwChooserField( if (_gear != null) content.Children.Add(_gear); Content = content; - IsEnabled = editContext != null && field.IsEditable; + // Read-only rows stay ENABLED: disabling the whole button would suppress its pointer + // events (killing hover-reveal) and disable the nested configure gear — which is + // NAVIGATION (the "Edit the … list" jump), not editing. Like FwDialogLauncherField, + // only the value-editing affordance is withheld: no option flyout is wired below, so + // clicking the value of a read-only row does nothing. AutomationProperties.SetAutomationId(this, automationId); AutomationProperties.SetName(this, field.Label ?? field.Field ?? automationId); diff --git a/Src/Common/FwAvalonia/Region/FwOptionPicker.cs b/Src/Common/FwAvalonia/Region/FwOptionPicker.cs index a280a339a1..1e5fc8f150 100644 --- a/Src/Common/FwAvalonia/Region/FwOptionPicker.cs +++ b/Src/Common/FwAvalonia/Region/FwOptionPicker.cs @@ -13,6 +13,7 @@ using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; +using Avalonia.VisualTree; using SIL.FieldWorks.Common.FwAvalonia.Poc; namespace SIL.FieldWorks.Common.FwAvalonia.Region @@ -51,8 +52,8 @@ public FwOptionPicker(IReadOnlyList options, _searchOptions = searchOptions; // A selection-filter panel, not a menu: light border + a hint of elevation. - Background = Brushes.White; - BorderBrush = Brushes.LightGray; + Background = PocDensity.PickerBackgroundBrush; + BorderBrush = PocDensity.PickerBorderBrush; BorderThickness = new Thickness(1); CornerRadius = new CornerRadius(3); Padding = new Thickness(4); @@ -100,8 +101,14 @@ public FwOptionPicker(IReadOnlyList options, OptionsList.AddHandler(InputElement.KeyDownEvent, OnListKeyDown, Avalonia.Interactivity.RoutingStrategies.Tunnel); // Click commits: selection lands on pointer press; the release completes the gesture. + // Releases on the list's OTHER template parts (scrollbar, padding) bubble through this + // handler too and must not commit — only a release that landed on an option row counts. OptionsList.AddHandler(InputElement.PointerReleasedEvent, - (s, e) => CommitHighlighted(), + (s, e) => + { + if (IsReleaseOverOwnItem(e.Source)) + CommitHighlighted(); + }, Avalonia.Interactivity.RoutingStrategies.Bubble); Child = new StackPanel @@ -139,6 +146,15 @@ public void CommitHighlighted() OptionCommitted?.Invoke(option); } + // True only when the routed event source sits inside a ListBoxItem of THIS picker's list — + // the guard that keeps scrollbar/track/padding releases from committing the highlight. + private bool IsReleaseOverOwnItem(object source) + { + var item = (source as Visual)?.GetSelfAndVisualAncestors() + .OfType().FirstOrDefault(); + return item != null && item.GetVisualAncestors().Contains(OptionsList); + } + private void ApplyFilter(string query) { if (_searchOptions != null) diff --git a/Src/Common/FwAvalonia/Region/HoverReveal.cs b/Src/Common/FwAvalonia/Region/HoverReveal.cs index 542c2cb720..3c884043b2 100644 --- a/Src/Common/FwAvalonia/Region/HoverReveal.cs +++ b/Src/Common/FwAvalonia/Region/HoverReveal.cs @@ -38,11 +38,19 @@ public static class HoverReveal /// The opacity fade duration (the "modern feel" transition). internal static readonly TimeSpan FadeDuration = TimeSpan.FromMilliseconds(120); + // The reveal registration of an affordance: stamped the first time the affordance is + // attached, looked up (and merged into) by every later Attach. The property is the + // idempotence anchor — without it each Attach call would stack an independent handler + // set with its own watched list, and the groups would fight over the opacity. + private static readonly AttachedProperty RevealGroupProperty = + AvaloniaProperty.RegisterAttached("HoverRevealGroup", typeof(HoverReveal)); + /// /// Wires to reveal while the pointer is over any of /// (or over an affordance itself) and hide otherwise. /// Idempotent per affordance: attaching again (the view widening the hover surface to the - /// row after the control wired itself) only adds the new sources. + /// row after the control wired itself) merges into the existing registration — one handler + /// set, one watched list — instead of stacking a second independent one. /// public static void Attach(IReadOnlyList hoverSources, IReadOnlyList affordances) { @@ -51,6 +59,23 @@ public static void Attach(IReadOnlyList hoverSources, IReadOnlyList()).Where(s => s != null).Distinct().ToList(); + // Resolve the registration this call lands in: the first already-registered target's + // group wins; targets registered in OTHER groups merge into it (an Attach spanning + // previously separate registrations unifies them — they reveal together from then on). + RevealGroup group = null; + foreach (var affordance in targets) + { + var existing = affordance.GetValue(RevealGroupProperty); + if (existing == null) + continue; + if (group == null) + group = existing; + else + existing.MergeInto(group); + } + if (group == null) + group = new RevealGroup(); + foreach (var affordance in targets) { // One opacity transition per affordance, even across repeated Attach calls. @@ -63,29 +88,92 @@ public static void Attach(IReadOnlyList hoverSources, IReadOnlyList group.RevealAll(); + affordance.LostFocus += (s, e) => group.Update(); } - SetRevealed(targets, false); - // The affordances are hover sources too: moving onto the gear keeps it revealed. - var watched = sources.Concat(targets).Distinct().ToList(); + foreach (var source in sources) + group.Watch(source); + + // Initial/merged state: hidden unless something is already hovered or focused. + group.Update(); + } + + // One reveal state machine per merged registration: the targets that reveal together and + // the controls whose hover drives them. Merging leaves a forwarding pointer behind, so + // handlers subscribed against an absorbed group keep working against the merged one. + private sealed class RevealGroup + { + private RevealGroup _mergedInto; + private readonly List _targets = new List(); + private readonly List _watched = new List(); - void Update() + private RevealGroup Resolve() { - var reveal = watched.Any(c => c.IsPointerOver) || targets.Any(a => a.IsFocused); - SetRevealed(targets, reveal); + var group = this; + while (group._mergedInto != null) + group = group._mergedInto; + return group; } - foreach (var control in watched) + public bool ContainsTarget(Control affordance) => Resolve()._targets.Contains(affordance); + + public void AddTarget(Control affordance) => Resolve()._targets.Add(affordance); + + /// Watches a hover source, subscribing its pointer handlers exactly once. + public void Watch(Control control) { + var group = Resolve(); + if (group._watched.Contains(control)) + return; + group._watched.Add(control); control.PointerEntered += (s, e) => Update(); control.PointerExited += (s, e) => Update(); } - foreach (var affordance in targets) + public void RevealAll() => SetRevealed(Resolve()._targets, true); + + public void Update() { - // Accessibility: opacity-hidden affordances stay focusable, so Tab reveals them. - affordance.GotFocus += (s, e) => SetRevealed(targets, true); - affordance.LostFocus += (s, e) => Update(); + var group = Resolve(); + var reveal = group._watched.Any(c => c.IsPointerOver) || group._targets.Any(a => a.IsFocused); + SetRevealed(group._targets, reveal); + } + + /// Folds this registration into (no-op when equal). + public void MergeInto(RevealGroup other) + { + var source = Resolve(); + var target = other.Resolve(); + if (source == target) + return; + foreach (var affordance in source._targets) + { + if (!target._targets.Contains(affordance)) + target._targets.Add(affordance); + } + foreach (var watched in source._watched) + { + if (!target._watched.Contains(watched)) + target._watched.Add(watched); + } + source._targets.Clear(); + source._watched.Clear(); + source._mergedInto = target; } } diff --git a/Src/Common/FwAvalonia/Region/LexicalEditFirstSlice.cs b/Src/Common/FwAvalonia/Region/LexicalEditFirstSlice.cs index e127087b1c..82930dca90 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditFirstSlice.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditFirstSlice.cs @@ -88,7 +88,7 @@ public static ViewDefinitionModel CompileFromLayoutDirectory(string partsDirecto var roots = new List { - StampProductLeaf(formNode, "LexemeFormEditor", "Lexeme Form"), + StampProductLeaf(formNode, "LexemeFormEditor", FwAvaloniaStrings.LexemeFormLabel), StampProductLeaf(morphTypeNode, "MorphTypeChooser", null), StampProductLeaf(glossNode, "SenseGlossEditor", null) }; @@ -113,9 +113,9 @@ public static ViewDefinitionModel AuthoredFallback() { var roots = new List { - Leaf("LexEntry/identity/#0", "Lexeme Form", "Form", "multistring", "all vernacular", "LexemeFormEditor"), - Leaf("LexEntry/identity/#1", "Morph Type", "MorphType", "morphtypeatomicreference", null, "MorphTypeChooser"), - Leaf("LexEntry/identity/#2", "Gloss", "Gloss", "multistring", "all analysis", "SenseGlossEditor") + Leaf("LexEntry/identity/#0", FwAvaloniaStrings.LexemeFormLabel, "Form", "multistring", "all vernacular", "LexemeFormEditor"), + Leaf("LexEntry/identity/#1", FwAvaloniaStrings.MorphTypeLabel, "MorphType", "morphtypeatomicreference", null, "MorphTypeChooser"), + Leaf("LexEntry/identity/#2", FwAvaloniaStrings.GlossLabel, "Gloss", "multistring", "all analysis", "SenseGlossEditor") }; var diagnostics = new[] diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs index c4ec712641..7f7a9eb055 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionMapper.cs @@ -81,24 +81,26 @@ private static LexicalEditRegionField BuildField(ViewNode node, IRegionValueProv } /// - /// Maps a node's editor to a renderable kind. Heuristic and deliberately small for the first - /// slice; extend as more editors gain Avalonia renderers. Obsolete editors are unsupported; - /// atomic-reference/chooser editors render as choosers; everything else is treated as text. + /// Maps a node's editor to a renderable kind. Obsolete editors are unsupported; the + /// chooser categories render as choosers; everything else is treated as text — the + /// deliberately small first-slice projection. The editor-string knowledge itself lives + /// ONCE, in (review consolidation: + /// this method previously kept its own substring heuristics, the third copy beside the + /// composer's switch and EditorKindMap's sets). /// private static RegionFieldKind ClassifyKind(ViewNode node) { if (node.EditorClassification == EditorClassification.Obsolete) return RegionFieldKind.Unsupported; - var editor = node.RawEditor ?? string.Empty; - if (editor.IndexOf("atomicreference", System.StringComparison.OrdinalIgnoreCase) >= 0 - || editor.IndexOf("chooser", System.StringComparison.OrdinalIgnoreCase) >= 0 - || editor.IndexOf("morphtype", System.StringComparison.OrdinalIgnoreCase) >= 0) + switch (EditorKindMap.ClassifyRegionFieldKind(node.RawEditor)) { - return RegionFieldKind.Chooser; + case RegionEditorCategory.MorphTypeChooser: + case RegionEditorCategory.AtomicReferenceChooser: + return RegionFieldKind.Chooser; + default: + return RegionFieldKind.Text; } - - return RegionFieldKind.Text; } } } diff --git a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs index 00084e8937..3532fac4e4 100644 --- a/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs +++ b/Src/Common/FwAvalonia/Region/LexicalEditRegionView.cs @@ -182,7 +182,7 @@ private Control BuildEditFooter() { _validationBlock = new TextBlock { - Foreground = Brushes.Firebrick, + Foreground = PocDensity.ValidationErrorBrush, TextWrapping = TextWrapping.Wrap, Margin = new Thickness(0, 4, 0, 2), IsVisible = false @@ -267,7 +267,7 @@ private void AddField(Grid grid, int row, LexicalEditRegionField field) withRule.Children.Add(new Border { Height = 2, - Background = Brushes.LightGray, + Background = PocDensity.SectionRuleBrush, Margin = new Thickness(0, 6, 0, 2) }); withRule.Children.Add(header); @@ -435,8 +435,12 @@ private static Control BuildImage(LexicalEditRegionField field, string automatio HorizontalAlignment = HorizontalAlignment.Left }; } - catch (Exception) + catch (Exception e) { + // Graceful degrade (legacy PictureSlice shows the path for a bad image), but + // never silently: record what failed to load and why. + System.Diagnostics.Debug.WriteLine( + $"Picture field '{field.StableId}' failed to load image '{path}': {e.Message}"); content = new TextBlock { Text = path, Foreground = Brushes.Gray }; } } diff --git a/Src/Common/FwAvalonia/Seams/FinalizerSafeSynchronizationContext.cs b/Src/Common/FwAvalonia/Seams/FinalizerSafeSynchronizationContext.cs index 78b9994dad..9b017c25d0 100644 --- a/Src/Common/FwAvalonia/Seams/FinalizerSafeSynchronizationContext.cs +++ b/Src/Common/FwAvalonia/Seams/FinalizerSafeSynchronizationContext.cs @@ -19,8 +19,9 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Seams /// InvalidOperationException → Control.MarshaledInvoke → BeginInvoke /// → WindowsFormsSynchronizationContext.Post → MicroCom.Runtime.MicroComProxyBase.Finalize(). /// Installed as the UI thread's ambient context BEFORE Avalonia initializes, this wrapper is - /// what every proxy captures; it delegates to the real context but swallows marshal failures - /// whose only victim would be a moot native Release. WinForms will not displace it — + /// what every proxy captures; it delegates to the real context but swallows POST marshal + /// failures whose only victim would be a moot native Release (synchronous Send failures still + /// surface — the caller is waiting on the result). WinForms will not displace it — /// InstallIfNeeded only replaces null/base-type contexts, never custom ones. /// public sealed class FinalizerSafeSynchronizationContext : SynchronizationContext @@ -60,19 +61,11 @@ public override void Post(SendOrPostCallback d, object state) } } - public override void Send(SendOrPostCallback d, object state) - { - try - { - _inner.Send(d, state); - } - catch (InvalidOperationException) - { - } - catch (InvalidAsynchronousStateException) - { - } - } + // Send is NOT swallowed: the finalizer rationale above only covers Post (MicroCom proxy + // finalizers post their native Release). Send is a synchronous call whose caller is + // waiting on the result — silently skipping the callback would corrupt that caller's + // state, so marshal failures surface to it. + public override void Send(SendOrPostCallback d, object state) => _inner.Send(d, state); public override SynchronizationContext CreateCopy() => new FinalizerSafeSynchronizationContext(_inner.CreateCopy()); diff --git a/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs b/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs index 83ea75e3e3..3023bcfec3 100644 --- a/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs +++ b/Src/Common/FwAvalonia/Seams/MorphTypeSwapLogic.cs @@ -2,6 +2,7 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System; using System.Collections.Generic; namespace SIL.FieldWorks.Common.FwAvalonia.Seams @@ -55,9 +56,57 @@ public static class MorphTypeSwapLogic MorphTypeKind.DiscontiguousPhrase }; + // Review consolidation (morph-type GUID knowledge): the ONE GUID → kind table. The seam is + // the cleaner home because it already owns MorphTypeKind and the stem/affix decision, and + // both the xWorks composer and any future surface can consume it without dragging WinForms + // along. This project is deliberately LCModel-free, so the fixed MoMorphTypeTags model GUIDs + // are mirrored as System.Guid literals; MorphTypeGuidConsolidationTests (xWorksTests, which + // references both assemblies) pins every literal to its MoMorphTypeTags constant so the + // mirror cannot drift. The legacy WinForms MorphTypeAtomicLauncher.IsStemType still carries + // its own guid list (DetailControls cannot reference FwAvalonia today); the same test pins + // that set too, and the launcher retires with its surface. + private static readonly IReadOnlyDictionary KindByGuid = + new Dictionary + { + { new Guid("d7f713e5-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Root }, + { new Guid("d7f713e8-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Stem }, + { new Guid("d7f713e4-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.BoundRoot }, + { new Guid("d7f713e7-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.BoundStem }, + { new Guid("56db04bf-3d58-44cc-b292-4c8aa68538f4"), MorphTypeKind.Particle }, + { new Guid("c2d140e5-7ca9-41f4-a69a-22fc7049dd2c"), MorphTypeKind.Clitic }, + { new Guid("d7f713e2-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Proclitic }, + { new Guid("d7f713e1-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Enclitic }, + { new Guid("a23b6faa-1052-4f4d-984b-4b338bdaf95f"), MorphTypeKind.Phrase }, + { new Guid("0cc8c35a-cee9-434d-be58-5d29130fba5b"), MorphTypeKind.DiscontiguousPhrase }, + { new Guid("d7f713db-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Prefix }, + { new Guid("d7f713dd-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Suffix }, + { new Guid("d7f713da-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Infix }, + { new Guid("d7f713dc-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Simulfix }, + { new Guid("d7f713de-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Suprafix }, + { new Guid("d7f713df-e8cf-11d3-9764-00c04f186933"), MorphTypeKind.Circumfix }, + { new Guid("af6537b0-7175-4387-ba6a-36547d37fb13"), MorphTypeKind.PrefixingInterfix }, + { new Guid("18d9b1c3-b5b6-4c07-b92c-2fe1d2281bd4"), MorphTypeKind.InfixingInterfix }, + { new Guid("3433683d-08a9-4bae-ae53-2a7798f64068"), MorphTypeKind.SuffixingInterfix } + }; + + /// + /// Classifies one of the fixed morph-type model GUIDs onto its . + /// False for anything else (a user-created morph type has no fixed GUID and no kind). + /// + public static bool TryClassify(Guid morphTypeGuid, out MorphTypeKind kind) + => KindByGuid.TryGetValue(morphTypeGuid, out kind); + /// True if the morph type is a stem-type (mirrors the legacy IsStemType). public static bool IsStemType(MorphTypeKind type) => StemTypes.Contains(type); + /// + /// True if the morph-type GUID classifies as a stem-type — the guid-level twin of the + /// legacy MorphTypeAtomicLauncher.IsStemType (an unknown guid is not a stem type, + /// exactly like the legacy null/guard behavior). + /// + public static bool IsStemType(Guid morphTypeGuid) + => TryClassify(morphTypeGuid, out var kind) && IsStemType(kind); + /// True if a swap from to crosses the stem/affix boundary. public static bool WouldCrossStemAffixBoundary(MorphTypeKind from, MorphTypeKind to) => IsStemType(from) != IsStemType(to); diff --git a/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs b/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs index fbb7a88534..07d2647ba1 100644 --- a/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs +++ b/Src/Common/FwAvalonia/ViewDefinition/EditorKindMap.cs @@ -7,15 +7,98 @@ namespace SIL.FieldWorks.Common.FwAvalonia.ViewDefinition { + /// + /// The renderable category of a legacy editor string — the ONE home for the + /// editor-string → category knowledge that the region composer's dispatch switch and + /// LexicalEditRegionMapper's kind classification both consume (review consolidation: + /// previously the composer's switch, the mapper's substring heuristics, and this map each + /// carried their own copy). Consumers may still refine a category by LCModel field type + /// (e.g. the composer's CellarPropertyType dispatch for ); only the + /// editor-string knowledge itself lives here. + /// + public enum RegionEditorCategory + { + /// A null/empty editor: a grouping node, no renderable field of its own. + Grouping, + + /// A (multi-)writing-system text editor (multistring/string). + Text, + + /// The morph-type chooser (morphtypeatomicreference) with its stem/affix guard. + MorphTypeChooser, + + /// An atomic-reference editor that renders as a chooser row. + AtomicReferenceChooser, + + /// A summary slice: a section header row in legacy too. + Summary, + + /// A lit slice: the label IS the content. + Literal, + + /// A picture/image slice. + Picture, + + /// A jtview embedded formatted view. + EmbeddedView, + + /// A command slice (button row). + Command, + + /// + /// An enumcombobox slice: legacy presents a CLOSED combo over the layout's + /// stringList labels (EnumComboSlice), never free-form input. + /// + EnumCombo, + + /// Anything else: consumers resolve by LCModel field type (or treat as text). + Other + } + /// /// Classifies a legacy editor string the same way SliceFactory.Create does: a fixed set of /// known statically-resolved editors, the dynamically loaded constructs, the obsolete ones, a /// grouping (null) editor, and everything else as unknown. This lets the typed importer raise /// faithful diagnostics for dynamic/unknown/obsolete editors (tasks 3.8 and 4.4) without - /// constructing any WinForms control. + /// constructing any WinForms control. Also the single home of the named editor-string + /// constants and the category API the region surfaces + /// dispatch on. /// public static class EditorKindMap { + /// The legacy multistring editor. + public const string MultiStringEditor = "multistring"; + + /// The legacy string editor. + public const string StringEditor = "string"; + + /// The legacy morphtypeatomicreference editor. + public const string MorphTypeAtomicReferenceEditor = "morphtypeatomicreference"; + + /// The legacy summary editor. + public const string SummaryEditor = "summary"; + + /// The legacy lit editor. + public const string LiteralEditor = "lit"; + + /// The legacy picture editor. + public const string PictureEditor = "picture"; + + /// The legacy image editor. + public const string ImageEditor = "image"; + + /// The legacy jtview editor. + public const string JtViewEditor = "jtview"; + + /// The legacy command editor. + public const string CommandEditor = "command"; + + /// The legacy enumcombobox editor. + public const string EnumComboBoxEditor = "enumcombobox"; + + /// The dynamically resolved per-type custom-field editor (autocustom). + public const string AutoCustomEditor = "autocustom"; + // Mirrors the case labels in Src/Common/Controls/DetailControls/SliceFactory.cs. Comparison is // case-insensitive because DataTree lowercases the editor attribute before dispatch // (DataTree.ProcessSubpartNode: editor.ToLower()), so e.g. "MorphTypeAtomicReference" in shipped @@ -94,5 +177,53 @@ public static EditorClassification Classify(string rawEditor) ? EditorClassification.Known : EditorClassification.Unknown; } + + /// + /// Maps a legacy editor string onto its renderable — + /// the one editor-string dispatch table the composer's field switch and the mapper's kind + /// classification share. Case-insensitive like the legacy DataTree dispatch + /// (editor.ToLower()). Editors not named here are : + /// consumers refine those by LCModel field type (the composer's WalkOtherField) + /// or render them as text (the first-slice mapper). + /// + public static RegionEditorCategory ClassifyRegionFieldKind(string rawEditor) + { + if (string.IsNullOrEmpty(rawEditor)) + { + return RegionEditorCategory.Grouping; + } + + switch (rawEditor.ToLowerInvariant()) + { + case MultiStringEditor: + case StringEditor: + return RegionEditorCategory.Text; + case MorphTypeAtomicReferenceEditor: + return RegionEditorCategory.MorphTypeChooser; + case SummaryEditor: + return RegionEditorCategory.Summary; + case LiteralEditor: + return RegionEditorCategory.Literal; + case PictureEditor: + case ImageEditor: + return RegionEditorCategory.Picture; + case JtViewEditor: + return RegionEditorCategory.EmbeddedView; + case CommandEditor: + return RegionEditorCategory.Command; + case EnumComboBoxEditor: + // Review task 2: a closed enum combo must never degrade to a free-form int + // editor that can persist invalid enum values. + return RegionEditorCategory.EnumCombo; + case "atomicreferencepos": + case "atomicreferenceposdisabled": + case "possatomicreference": + case "defaultatomicreference": + case "defaultatomicreferencedisabled": + return RegionEditorCategory.AtomicReferenceChooser; + default: + return RegionEditorCategory.Other; + } + } } } diff --git a/Src/xWorks/AvaloniaRegionRefreshController.cs b/Src/xWorks/AvaloniaRegionRefreshController.cs index e8e47f5303..712bb4a24d 100644 --- a/Src/xWorks/AvaloniaRegionRefreshController.cs +++ b/Src/xWorks/AvaloniaRegionRefreshController.cs @@ -34,16 +34,30 @@ public sealed class AvaloniaRegionRefreshController : IVwNotifyChange, IDisposab private readonly Action _refresh; private readonly ILexicalRefreshCoordinator _coordinator; private readonly Action _schedule; + private readonly Func _isRelevant; private bool _refreshQueued; private bool _disposed; + /// The shared LCModel cache whose notification bus is observed. + /// The record the surface is displaying right now. + /// Whether the surface's own edit session is open. + /// Re-resolves/re-shows the region from current domain state. + /// The suspend/pending gate used while editing. + /// Optional UI-thread deferral for coalesced delivery. + /// + /// Host-supplied relevance for a changed object that is NOT the displayed record itself + /// (the controller already treats the displayed record as relevant). The lexical host + /// supplies the ILexEntry owner-walk (see RecordEditView.EnsureAvaloniaRefreshController); + /// when null, only changes to the displayed record itself trigger a refresh. + /// public AvaloniaRegionRefreshController( LcmCache cache, Func currentRecord, Func isEditing, Action refresh, ILexicalRefreshCoordinator coordinator, - Action schedule = null) + Action schedule = null, + Func isRelevant = null) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _currentRecord = currentRecord ?? throw new ArgumentNullException(nameof(currentRecord)); @@ -51,6 +65,7 @@ public AvaloniaRegionRefreshController( _refresh = refresh ?? throw new ArgumentNullException(nameof(refresh)); _coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); _schedule = schedule; + _isRelevant = isRelevant; cache.DomainDataByFlid.AddNotification(this); } @@ -84,18 +99,6 @@ public void PropChanged(int hvo, int tag, int ivMin, int cvIns, int cvDel) ScheduleRefresh(); } - /// - /// Called by the host when its edit session committed or cancelled: delivers any refresh that - /// was held while editing. - /// - public void NotifyEditCompleted() - { - if (_disposed) - return; - if (_coordinator.IsSuspended && _coordinator.EndSuspend()) - ScheduleRefresh(); - } - /// /// Host-initiated refresh (e.g. after a commit/cancel completed) routed through the SAME /// coalesced, editing-aware queue as PropChanged deliveries, so a completion plus a @@ -173,7 +176,9 @@ void Runner() } } - // A change is relevant when the changed object is, or is owned by, the entry on display. + // A change to the displayed record itself is always relevant; anything else is the host's + // call (the lexical host walks OwnerOfClass — injected, not hard-coded here, so + // non-lexical hosts can reuse the controller with their own containment rule). private bool IsRelevant(int hvo) { var current = _currentRecord(); @@ -184,8 +189,7 @@ private bool IsRelevant(int hvo) if (!_cache.ServiceLocator.ObjectRepository.TryGetObject(hvo, out var changed)) return false; - var owningEntry = changed as ILexEntry ?? changed.OwnerOfClass(); - return owningEntry != null && owningEntry.Hvo == current.Hvo; + return _isRelevant != null && _isRelevant(changed); } public void Dispose() diff --git a/Src/xWorks/ChorusNotesPlugin.cs b/Src/xWorks/ChorusNotesPlugin.cs index 287460b0ad..ff522b4110 100644 --- a/Src/xWorks/ChorusNotesPlugin.cs +++ b/Src/xWorks/ChorusNotesPlugin.cs @@ -47,9 +47,16 @@ public sealed class ChorusNotesStore : IDisposable private readonly AnnotationRepository _primary; private readonly List _additional = new List(); private readonly MultiSourceAnnotationRepository _all; - private readonly StoreObserver _observer; + private readonly List _watchers = new List(); + private readonly object _watchGate = new object(); + private System.Threading.Timer _notesChangedDebounce; private bool _disposed; + // Coalesces FileSystemWatcher bursts (one save raises several events) and gives the + // repository's OWN reload of the same file (same event, its watcher thread) time to land + // before consumers re-query. The legacy bar polled at 500 ms; half that keeps us snappier. + private const int NotesChangedDebounceMilliseconds = 250; + /// /// Opens (creating as needed, §1) the project's lexicon notes store: the primary repository /// on Lexicon.fwstub.ChorusNotes keyed by the id ref parameter, plus one @@ -76,13 +83,59 @@ public ChorusNotesStore(string projectFolder) } _all = new MultiSourceAnnotationRepository(_primary, _additional.Cast()); - // External refresh (§6): each repository owns a FileSystemWatcher on its file and raises - // NotifyOfStaleList on external change (e.g. after S/R); observing surfaces that as - // NotesChanged — no legacy 500 ms polling timer. - _observer = new StoreObserver(this); - _primary.AddObserver(_observer, progress); + // External refresh (§6): watch each notes file OURSELVES instead of registering an + // IAnnotationRepositoryObserver. The shipped LibChorus enumerates its observer list on + // its FileSystemWatcher thread with no lock (AnnotationRepository.UnderlyingFileChanged + // → _observers.ForEach), so Add/RemoveObserver from any other thread can race that + // enumeration — and the resulting InvalidOperationException on an IO-completion thread + // terminates the PROCESS. Owning the watcher leaves the repository's observer list + // untouched after construction (only its internal key index, registered in its ctor, + // remains) and gives this store a teardown it fully controls. Local writes raise + // NotesChanged directly from the mutating methods instead. + WatchNotesFile(_primary.AnnotationFilePath); foreach (var repository in _additional) - repository.AddObserver(_observer, progress); + WatchNotesFile(repository.AnnotationFilePath); + } + + private void WatchNotesFile(string path) + { + var watcher = new FileSystemWatcher(Path.GetDirectoryName(path), Path.GetFileName(path)) + { + NotifyFilter = NotifyFilters.LastWrite + }; + watcher.Changed += OnNotesFileChanged; + watcher.Created += OnNotesFileChanged; + watcher.EnableRaisingEvents = true; + _watchers.Add(watcher); + } + + // Runs on the watcher's IO-completion thread: an escaping exception there terminates the + // process, so everything is guarded; the debounce timer turns an event burst into one raise. + private void OnNotesFileChanged(object sender, FileSystemEventArgs e) + { + try + { + lock (_watchGate) + { + if (_disposed) + return; + if (_notesChangedDebounce == null) + { + _notesChangedDebounce = new System.Threading.Timer( + _ => RaiseNotesChanged(), null, + NotesChangedDebounceMilliseconds, System.Threading.Timeout.Infinite); + } + else + { + _notesChangedDebounce.Change( + NotesChangedDebounceMilliseconds, System.Threading.Timeout.Infinite); + } + } + } + catch (Exception ex) + { + Logger.WriteError("ChorusNotesStore: notes file watcher failed.", ex); + } } /// Full path of Lexicon.fwstub.ChorusNotes. @@ -95,7 +148,21 @@ public ChorusNotesStore(string projectFolder) /// public event EventHandler NotesChanged; - private void RaiseNotesChanged() => NotesChanged?.Invoke(this, EventArgs.Empty); + // Raised from the debounce timer's pool thread or a mutating method's calling thread; an + // exception escaping a pool thread terminates the process, so consumer failures are logged. + private void RaiseNotesChanged() + { + if (_disposed) + return; + try + { + NotesChanged?.Invoke(this, EventArgs.Empty); + } + catch (Exception ex) + { + Logger.WriteError("ChorusNotesStore: a NotesChanged consumer failed.", ex); + } + } /// /// The silfw link new FLEx lexicon notes carry (contract §4, verbatim template from @@ -147,6 +214,7 @@ public Annotation AddNote(string entryGuid, string label, string text) annotation.AddMessage(Environment.UserName, null, text); // null status inherits "" (§5.4) _all.AddAnnotation(annotation); // multi-source routes new notes to the primary (§3.3) _primary.SaveNowIfNeeded(new NullProgress()); // immediate flush (§5.2) + RaiseNotesChanged(); // local writes notify directly (no repository observer; see ctor) return annotation; } @@ -157,6 +225,7 @@ public bool AppendMessage(Annotation annotation, string text) return false; annotation.AddMessage(Environment.UserName, null, text); SaveOwnerOf(annotation); + RaiseNotesChanged(); return true; } @@ -174,6 +243,7 @@ public bool ToggleResolved(Annotation annotation) annotation.SetStatus(Environment.UserName, annotation.IsClosed ? Annotation.Open : Annotation.Closed); SaveOwnerOf(annotation); + RaiseNotesChanged(); return true; } @@ -218,41 +288,33 @@ private static IEnumerable GetAdditionalLexiconFilePaths(string projectF } } - /// Dispose order per §6: unhook, then dispose — Dispose performs the final SaveNowIfNeeded. + /// + /// Dispose order per §6: stop OUR watchers and debounce first (under the gate, so an + /// in-flight watcher callback observes _disposed), then dispose the repositories — each + /// repository's Dispose performs the final SaveNowIfNeeded. + /// public void Dispose() { - if (_disposed) - return; - _disposed = true; - _primary.RemoveObserver(_observer); - foreach (var repository in _additional) - repository.RemoveObserver(_observer); + lock (_watchGate) + { + if (_disposed) + return; + _disposed = true; + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Changed -= OnNotesFileChanged; + watcher.Created -= OnNotesFileChanged; + watcher.Dispose(); + } + _watchers.Clear(); + _notesChangedDebounce?.Dispose(); + _notesChangedDebounce = null; + } _primary.Dispose(); foreach (var repository in _additional) repository.Dispose(); } - - private sealed class StoreObserver : IAnnotationRepositoryObserver - { - private readonly ChorusNotesStore _store; - - public StoreObserver(ChorusNotesStore store) - { - _store = store; - } - - public void Initialize(Func> allAnnotationsFunction, IProgress progress) - { - } - - public void NotifyOfAddition(Annotation annotation) => _store.RaiseNotesChanged(); - - public void NotifyOfModification(Annotation annotation) => _store.RaiseNotesChanged(); - - public void NotifyOfDeletion(Annotation annotation) => _store.RaiseNotesChanged(); - - public void NotifyOfStaleList() => _store.RaiseNotesChanged(); - } } /// @@ -403,8 +465,10 @@ public sealed class ChorusNotesPlugin : IRegionEditorPlugin { public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; - public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache) + public Control BuildControl(RegionEditorBuildContext context) { + var obj = context?.Target; + var cache = context?.Cache; if (obj == null || cache == null) return null; ChorusNotesStore store = null; diff --git a/Src/xWorks/DialogLauncherPlugins.cs b/Src/xWorks/DialogLauncherPlugins.cs index e33fe90583..76b18e7609 100644 --- a/Src/xWorks/DialogLauncherPlugins.cs +++ b/Src/xWorks/DialogLauncherPlugins.cs @@ -17,11 +17,11 @@ namespace SIL.FieldWorks.XWorks /// + "..." button) whose button calls the host-injected /// seam with the row's (object, node). The pane stays WinForms-free; the WinForms dialog is /// the sanctioned coexistence carve-out and lives behind the seam. Without injected services - /// the value still renders and the button is disabled with a tooltip. The fenced edit context - /// is unused: the dialog commits through its own UOW and the refresh controller re-renders via - /// PropChanged. + /// (context.Services null, or no launcher in them) the value still renders and the button is + /// disabled with a tooltip. The fenced edit context is unused: the dialog commits through its + /// own UOW and the refresh controller re-renders via PropChanged. /// - public sealed class LauncherRegionPlugin : IServiceAwareRegionEditorPlugin + public sealed class LauncherRegionPlugin : IRegionEditorPlugin { private readonly Func _valueReader; @@ -34,20 +34,17 @@ public LauncherRegionPlugin(string legacyClassName, public string LegacyClassName { get; } - public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, - LcmCache cache) - => BuildControl(obj, node, editContext, cache, null); - - public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, - LcmCache cache, RegionEditorServices services) + public Control BuildControl(RegionEditorBuildContext context) { + var obj = context?.Target; + var node = context?.Node; if (obj == null || node == null) return null; string value; try { - value = _valueReader(obj, node, cache) ?? string.Empty; + value = _valueReader(obj, node, context.Cache) ?? string.Empty; } catch (Exception e) { @@ -56,7 +53,7 @@ public Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext edi value = string.Empty; } - var launcher = services?.LegacyDialogLauncher; + var launcher = context.Services?.LegacyDialogLauncher; Action launch = launcher == null ? (Action)null : () => diff --git a/Src/xWorks/FullEntryRegionComposer.cs b/Src/xWorks/FullEntryRegionComposer.cs index cebac11d43..4a750c7452 100644 --- a/Src/xWorks/FullEntryRegionComposer.cs +++ b/Src/xWorks/FullEntryRegionComposer.cs @@ -65,7 +65,24 @@ public static class FullEntryRegionComposer { private const int MaxDepth = 6; private static readonly ViewDefinitionCompiler Compiler = new ViewDefinitionCompiler(); - private static readonly Lazy Sources = new Lazy(LoadSources); + + // Review task 10: deliberately NOT a Lazy — a failed load must not be cached as null for + // the process lifetime (the old behavior: one transient IO hiccup silently demoted every + // future compose to the 3-field first slice). A successful load is immutable and cached + // forever; a failure logs (see LoadSources) and is retried on the next compose. + private static readonly object SourcesSync = new object(); + private static CompilerSources s_sources; + + private static CompilerSources GetSources() + { + var sources = s_sources; + if (sources != null) + return sources; + lock (SourcesSync) + { + return s_sources ?? (s_sources = LoadSources()); + } + } // Review finding A (observable memoization): counts the expensive snapshot builds (layout // lookup + layout.ToString() + fingerprint + compile). A repeat compose must not grow it. @@ -119,25 +136,14 @@ public static ComposedEntryRegion Compose(ILexEntry entry, LcmCache cache, bool /// chooser options, hierarchy carried as — exactly /// the indented tree the legacy chooser shows. (a chooserInfo /// "FlatList" guicontrol spec, e.g. PeopleFlatList) keeps the order but suppresses the - /// hierarchy, like the legacy flat chooser. + /// hierarchy, like the legacy flat chooser. Review task 12: the implementation lives in + /// the shared so this composer and + /// cannot drift; this wrapper keeps the composer's + /// established internal surface (and its tests). /// internal static IReadOnlyList BuildPossibilityOptions( ICmPossibilityList list, bool flat) - { - var options = new List(); - void Add(ICmPossibility possibility, int depth) - { - options.Add(new RegionChoiceOption(possibility.Guid.ToString(), - possibility.Name.BestAnalysisAlternative?.Text ?? possibility.ShortName ?? possibility.Guid.ToString(), - flat ? 0 : depth)); - foreach (var sub in possibility.SubPossibilitiesOS) - Add(sub, depth + 1); - } - - foreach (var possibility in list.PossibilitiesOS) - Add(possibility, 0); - return options; - } + => RegionValueFactory.BuildPossibilityOptions(list, flat); // The legacy generic possibility-list → lists-area-tool derivation, mirrored statically. // Research (gear = configure): when a legacy jump's target object is owned by a @@ -555,17 +561,17 @@ private ViewNode MakeCustomFieldNode(ViewNode placeholder, int flid) switch ((CellarPropertyType)_mdc.GetFieldType(flid)) { case CellarPropertyType.String: - rawEditor = "string"; + rawEditor = EditorKindMap.StringEditor; wsSpec = WritingSystemServices.GetMagicWsNameFromId(_mdc.GetFieldWs(flid)); break; case CellarPropertyType.MultiUnicode: case CellarPropertyType.MultiString: - rawEditor = "multistring"; + rawEditor = EditorKindMap.MultiStringEditor; wsSpec = WritingSystemServices.GetMagicWsNameFromId(_mdc.GetFieldWs(flid)); break; default: // Resolved by CellarPropertyType in WalkOtherField, like autoCustom. - rawEditor = "autocustom"; + rawEditor = EditorKindMap.AutoCustomEditor; break; } @@ -597,8 +603,11 @@ private IReadOnlyList BuildChooserLinks(ViewNode node, if (!string.Equals(link.Type, "goto", StringComparison.OrdinalIgnoreCase) || string.IsNullOrEmpty(link.Label) || string.IsNullOrEmpty(link.Tool)) { - System.Diagnostics.Debug.WriteLine( - $"chooserLink type '{link.Type}' (tool '{link.Tool}') on {node.StableId} is not the goto kind the lexeme editor uses; skipped."); + // Review task 10: skipped links must be visible in the product log, not + // only on a debugger (the legacy "dialog"/"simple" kinds wait on the + // ChooserCommand lanes). + SIL.Reporting.Logger.WriteEvent( + $"FullEntryRegionComposer: chooserLink type '{link.Type}' (tool '{link.Tool}') on {node.StableId} is not the goto kind the lexeme editor uses; skipped."); continue; } (links ?? (links = new List())) @@ -679,34 +688,37 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) } var fieldCountBeforeDispatch = Fields.Count; - var editor = (node.RawEditor ?? "").ToLowerInvariant(); - switch (editor) + // Review task 8: the editor-string → category knowledge lives ONCE, in + // EditorKindMap (the same FwAvalonia home the importer's classification and the + // mapper's kind projection use); this switch only routes categories. Categories + // without a dedicated lane here (AtomicReferenceChooser, Grouping, Other) refine + // by CellarPropertyType in WalkOtherField — that LCModel knowledge stays in the + // composer. + switch (EditorKindMap.ClassifyRegionFieldKind(node.RawEditor)) { - case "multistring": - case "string": + case RegionEditorCategory.Text: WalkTextField(node, obj, depth); break; - case "morphtypeatomicreference": + case RegionEditorCategory.MorphTypeChooser: WalkMorphTypeChooser(node, obj, depth); break; - case "summary": + case RegionEditorCategory.Summary: // Summary slices are section header rows in legacy too. AddHeader(node, obj, depth, Localize(node.Label) ?? node.Field); break; - case "lit": + case RegionEditorCategory.Literal: // Literal text row: the label IS the content. AddReadOnlyRow(node, obj, depth, string.Empty); break; - case "picture": - case "image": + case RegionEditorCategory.Picture: WalkPictures(node, obj, depth); break; - case "jtview": + case RegionEditorCategory.EmbeddedView: // Embedded formatted view: render the object's summary text read-only (the // full embedded-view replacement rides the table/IR work). AddReadOnlyRow(node, obj, depth, obj.ShortName ?? string.Empty); break; - case "command": + case RegionEditorCategory.Command: // Command slices render their button; execution arrives with the xCore // command bridge (shell phase). Fields.Add(new LexicalEditRegionField(StableId(node, obj), @@ -715,6 +727,9 @@ private void WalkField(ViewNode node, ICmObject obj, int depth) node.LocalizationKey, node.Routing, null, null, null, isEditable: false, indent: depth)); break; + case RegionEditorCategory.EnumCombo: + WalkEnumCombo(node, obj, depth); + break; default: WalkOtherField(node, obj, depth); break; @@ -751,43 +766,88 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) } var type = (CellarPropertyType)_mdc.GetFieldType(flid); - var systems = ResolveWritingSystems(_cache, node.WritingSystem); - var values = new List(); + switch (type) + { + case CellarPropertyType.MultiUnicode: + case CellarPropertyType.MultiString: + case CellarPropertyType.String: + case CellarPropertyType.Unicode: + break; + default: + WalkUnsupported(node, obj, depth); + return; + } + + var hvo = obj.Hvo; + IReadOnlyList systems = ResolveWritingSystems(_cache, node.WritingSystem); + if ((type == CellarPropertyType.String || type == CellarPropertyType.Unicode) + && systems.Count > 0) + { + // Single-alternative property: one row. Review task 5 (plain String props): + // get_StringProp reads the WHOLE string regardless of the layout ws= spec, but + // the row's display metadata (abbreviation/font/RTL) and write-back previously + // took the spec's FIRST writing system — asymmetric when the stored string was + // typed in another ws (legacy StringSlice renders the string's own run + // properties). Derive the row's ws from the existing string's first run; the + // layout ws only seeds an EMPTY string. + var rowWs = systems[0]; + if (type == CellarPropertyType.String) + { + var existing = _sda.get_StringProp(hvo, flid); + if (existing != null && existing.Length > 0) + { + var runWs = TsStringUtils.GetWsOfRun(existing, 0); + if (runWs > 0) + { + try + { + rowWs = _cache.ServiceLocator.WritingSystemManager.Get(runWs); + } + catch (Exception) + { + // Unknown run ws: keep the layout writing system. + } + } + } + } + systems = new[] { rowWs }; + } + var anyData = false; - foreach (var ws in systems) + var rich = false; + // 11.15: the lexeme form's legacy bold/120% emphasis. + var fontSize = node.FontScalePercent > 0 ? 12.0 * node.FontScalePercent / 100.0 : 0; + // Review task 12: the per-ws value rows build through the shared factory + // (LexicalEditRegionBuilder uses the same one), this lane only supplies the text. + var values = RegionValueFactory.BuildMultiWsValues(systems, ws => { string text; - switch (type) + if (type == CellarPropertyType.Unicode) { - case CellarPropertyType.MultiUnicode: - case CellarPropertyType.MultiString: - text = _sda.get_MultiStringAlt(obj.Hvo, flid, ws.Handle)?.Text; - break; - case CellarPropertyType.String: - text = _sda.get_StringProp(obj.Hvo, flid)?.Text; - break; - case CellarPropertyType.Unicode: - text = _sda.get_UnicodeProp(obj.Hvo, flid); - break; - default: - WalkUnsupported(node, obj, depth); - return; + text = _sda.get_UnicodeProp(hvo, flid); + } + else + { + var tss = ReadTextProp(hvo, flid, ws.Handle, type); + // Review task 4: rich content (multiple runs, or props beyond the ws) + // makes the whole row read-only below. + rich |= HasRichContent(tss); + text = tss?.Text; } - anyData |= !string.IsNullOrEmpty(text); - // 11.15: the lexeme form's legacy bold/120% emphasis. - var fontSize = node.FontScalePercent > 0 ? 12.0 * node.FontScalePercent / 100.0 : 0; - values.Add(new RegionWsValue(ws.Abbreviation, text ?? string.Empty, ws.DefaultFontName, - fontSize, ws.RightToLeftScript, ws.Id, node.BoldEmphasis)); - if (type == CellarPropertyType.String || type == CellarPropertyType.Unicode) - break; // single-alternative property: one row - } + return text; + }, fontSize, node.BoldEmphasis); if (!anyData && HideWhenEmpty(node)) return; var stableId = StableId(node, obj); - var editable = type != CellarPropertyType.Unicode; + // Review task 4: the plain-text setter below replaces the WHOLE alternative via + // MakeString, which would flatten embedded writing systems, styles, and any other + // run properties on the first keystroke. Until the rich TsString editor lands + // (gated on 6.13), a row whose current content is rich composes READ-ONLY so a + // keystroke cannot destroy it; plain single-run content stays editable. + var editable = type != CellarPropertyType.Unicode && !rich; Fields.Add(new LexicalEditRegionField(stableId, Localize(node.Label) ?? node.Field, node.Field, node.WritingSystem, RegionFieldKind.Text, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, values, null, null, editable, depth, @@ -797,7 +857,6 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) if (!editable) return; - var hvo = obj.Hvo; // Edits key on the unique IETF tag (ws.Id): the user-editable Abbreviation can // collide across writing systems, which both crashed composition (ToDictionary) // and could misroute an edit to the wrong alternative. Unambiguous abbreviations @@ -820,13 +879,65 @@ private void WalkTextField(ViewNode node, ICmObject obj, int depth) { if (wsKey == null || !wsByKey.TryGetValue(wsKey, out var wsHandle)) return false; - var tss = TsStringUtils.MakeString(value ?? string.Empty, wsHandle); - if (type == CellarPropertyType.String) + return WriteTextProp(hvo, flid, wsHandle, type, value); + }; + } + + // Review task 11: the ONE String-vs-multi text read dispatch every TsString-reading + // site shares (Unicode props return a raw string and stay with get_UnicodeProp at the + // call sites). + private ITsString ReadTextProp(int hvo, int flid, int ws, CellarPropertyType type) + { + switch (type) + { + case CellarPropertyType.MultiUnicode: + case CellarPropertyType.MultiString: + return _sda.get_MultiStringAlt(hvo, flid, ws); + case CellarPropertyType.String: + return _sda.get_StringProp(hvo, flid); + default: + return null; + } + } + + // Review task 11: the matching write dispatch (plain-text MakeString round-trip; the + // rich-content guard in WalkTextField keeps it away from rich strings). + private bool WriteTextProp(int hvo, int flid, int ws, CellarPropertyType type, string value) + { + var tss = TsStringUtils.MakeString(value ?? string.Empty, ws); + switch (type) + { + case CellarPropertyType.String: _sda.SetString(hvo, flid, tss); - else - _sda.SetMultiStringAlt(hvo, flid, wsHandle, tss); + return true; + case CellarPropertyType.MultiUnicode: + case CellarPropertyType.MultiString: + _sda.SetMultiStringAlt(hvo, flid, ws, tss); + return true; + default: + return false; + } + } + + // Review task 4: "rich" = more than one run, or single-run properties beyond the + // writing system itself — exactly the content a plain-text MakeString round-trip + // would silently destroy. + private static bool HasRichContent(ITsString tss) + { + if (tss == null || tss.Length == 0) + return false; + if (tss.RunCount > 1) return true; - }; + var props = tss.get_Properties(0); + if (props.StrPropCount > 0) + return true; + for (var i = 0; i < props.IntPropCount; i++) + { + props.GetIntProp(i, out var tpt, out _); + if (tpt != (int)FwTextPropType.ktptWs) + return true; + } + return false; } private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) @@ -872,8 +983,11 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) // prompt AND a class conversion (MoStemAllomorph <-> MoAffixAllomorph). Assigning // blindly would create a model-invalid combination (e.g. a stem allomorph with an // affix morph type), so a boundary-crossing assignment is rejected until the - // class-conversion lane lands (review round 2). - if (TryClassifyMorphType(guid, out var toKind) + // class-conversion lane lands (review round 2). The GUID -> kind classification + // is the seam's single table (review consolidation: this file's 19-entry mirror + // dictionary is gone; MorphTypeGuidConsolidationTests pins the seam's table to + // the MoMorphTypeTags constants). + if (MorphTypeSwapLogic.TryClassify(guid, out var toKind) && (form is IMoStemAllomorph) != MorphTypeSwapLogic.IsStemType(toKind)) { return false; @@ -883,44 +997,27 @@ private void WalkMorphTypeChooser(ViewNode node, ICmObject obj, int depth) }; } - // Maps the fixed MoMorphTypeTags GUIDs onto the seam's MorphTypeKind so the pure - // stem/affix boundary logic (extracted from MorphTypeAtomicLauncher) can classify them. - private static readonly IReadOnlyDictionary MorphTypeKindByGuid = - new Dictionary - { - { MoMorphTypeTags.kguidMorphRoot, MorphTypeKind.Root }, - { MoMorphTypeTags.kguidMorphStem, MorphTypeKind.Stem }, - { MoMorphTypeTags.kguidMorphBoundRoot, MorphTypeKind.BoundRoot }, - { MoMorphTypeTags.kguidMorphBoundStem, MorphTypeKind.BoundStem }, - { MoMorphTypeTags.kguidMorphParticle, MorphTypeKind.Particle }, - { MoMorphTypeTags.kguidMorphClitic, MorphTypeKind.Clitic }, - { MoMorphTypeTags.kguidMorphProclitic, MorphTypeKind.Proclitic }, - { MoMorphTypeTags.kguidMorphEnclitic, MorphTypeKind.Enclitic }, - { MoMorphTypeTags.kguidMorphPhrase, MorphTypeKind.Phrase }, - { MoMorphTypeTags.kguidMorphDiscontiguousPhrase, MorphTypeKind.DiscontiguousPhrase }, - { MoMorphTypeTags.kguidMorphPrefix, MorphTypeKind.Prefix }, - { MoMorphTypeTags.kguidMorphSuffix, MorphTypeKind.Suffix }, - { MoMorphTypeTags.kguidMorphInfix, MorphTypeKind.Infix }, - { MoMorphTypeTags.kguidMorphSimulfix, MorphTypeKind.Simulfix }, - { MoMorphTypeTags.kguidMorphSuprafix, MorphTypeKind.Suprafix }, - { MoMorphTypeTags.kguidMorphCircumfix, MorphTypeKind.Circumfix }, - { MoMorphTypeTags.kguidMorphPrefixingInterfix, MorphTypeKind.PrefixingInterfix }, - { MoMorphTypeTags.kguidMorphInfixingInterfix, MorphTypeKind.InfixingInterfix }, - { MoMorphTypeTags.kguidMorphSuffixingInterfix, MorphTypeKind.SuffixingInterfix } - }; - - private static bool TryClassifyMorphType(Guid guid, out MorphTypeKind kind) - => MorphTypeKindByGuid.TryGetValue(guid, out kind); - // 6.3: an atomic possibility reference takes the chooser lane (legacy // PossibilityAtomicReferenceSlice): options from the field's own list // (ReferenceTargetOwner), write-back through the fenced session. private void AddAtomicPossibilityChooser(ViewNode node, ICmObject obj, int depth, int flid, ICmPossibilityList list, int targetHvo) { + // Review task 6: the legacy atomic possibility launcher lets the user CLEAR the + // reference (PossibilityAtomicReferenceLauncher.OnLeave -> AddItem(null) when the + // box is emptied; only a layout-authored nullLabel="" forbids it, which no + // lexeme-editor part does), so the chooser leads with an explicit empty choice — + // labeled with the SAME localized "" the WinForms launchers use + // (DetailControlsStrings.ksNullLabel). The morph-type chooser deliberately offers + // no empty option (MorphTypeAtomicLauncher.AllowEmptyItem == false). + var options = new List + { + new RegionChoiceOption(string.Empty, + SIL.FieldWorks.Common.Framework.DetailControls.DetailControlsResourceAccess.NullItemLabel) + }; // B7 remainder: chooserInfo FlatList specs are not yet imported onto the node; // until they are, the chooser renders the list's own hierarchy. - var options = BuildPossibilityOptions(list, flat: false); + options.AddRange(BuildPossibilityOptions(list, flat: false)); var selected = targetHvo == 0 ? null : _cache.ServiceLocator.ObjectRepository.GetObject(targetHvo).Guid.ToString(); @@ -934,6 +1031,13 @@ private void AddAtomicPossibilityChooser(ViewNode node, ICmObject obj, int depth var hvo = obj.Hvo; OptionSetters[stableId] = key => { + // The empty option clears the reference — legacy AddItem(null), i.e. + // SetObjProp(hvo, flid, 0) — inside the same fenced session (task 6). + if (string.IsNullOrEmpty(key)) + { + _sda.SetObjProp(hvo, flid, 0); + return true; + } var possibility = ResolvePossibilityInList(list, key); if (possibility == null) return false; @@ -1203,6 +1307,43 @@ private static bool StartsWithIgnoreCase(string text, string query) => !string.IsNullOrEmpty(text) && text.StartsWith(query, StringComparison.OrdinalIgnoreCase); + // Review task 2: legacy enumComboBox is a CLOSED combo over the layout's stringList + // labels (SliceFactory.cs case "enumcombobox" -> EnumComboSlice), never free-form + // input. Falling through to the Integer lane composed it as an unrestricted int + // editor whose SetInt could persist invalid enum values. Until the importer carries + // the layout's stringList ids onto the node (it currently drops them), the row + // composes READ-ONLY showing the raw stored value; the eventual fix is an option + // chooser fed by that stringList. + private void WalkEnumCombo(ViewNode node, ICmObject obj, int depth) + { + var flid = GetFlid(obj, node.Field); + if (flid == 0) + { + WalkUnsupported(node, obj, depth); + return; + } + + int current; + switch ((CellarPropertyType)_mdc.GetFieldType(flid)) + { + case CellarPropertyType.Integer: + current = _sda.get_IntProp(obj.Hvo, flid); + break; + case CellarPropertyType.Boolean: + // Legacy EnumComboSlice serves boolean-backed enums too (e.g. the + // Allomorph Status combo over IsAbstract), via IntBoolPropertyConverter. + current = IntBoolPropertyConverter.GetBoolean(_sda, obj.Hvo, flid) ? 1 : 0; + break; + default: + WalkUnsupported(node, obj, depth); + return; + } + + if (current == 0 && HideWhenEmpty(node)) + return; + AddReadOnlyRow(node, obj, depth, current.ToString(CultureInfo.InvariantCulture)); + } + // Viewing parity (11.x): every field type the legacy slices display has a rendering here: // booleans as checkboxes (editable), integers editable, dates/gendates formatted, // structured text as paragraph text, references as value rows; explicit unsupported rows @@ -1336,8 +1477,20 @@ private void WalkOtherField(ViewNode node, ICmObject obj, int depth) var hvo = obj.Hvo; TextSetters[stableId] = (ws, value) => { - if (!int.TryParse(value, out var parsed)) + // Review task 7 (clearing an int box): legacy IntegerSlice treats a + // non-numeric box — INCLUDING empty — as invalid on focus loss: it + // warns and restores the stored value, never committing empty as 0 + // (BasicTypeSlices.cs, IntegerSlice.m_tb_LostFocus's + // Convert.ToInt32 FormatException path). Mirror that deliberately: + // empty/whitespace stages NOTHING (false), so the control restores + // the last committed value (its lastStaged advances only on + // success), and a clear-then-retype only ever stages the parseable + // intermediate states. + if (!int.TryParse(value, NumberStyles.Integer, + CultureInfo.InvariantCulture, out var parsed)) + { return false; + } _sda.SetInt(hvo, flid, parsed); return true; }; @@ -1397,14 +1550,11 @@ private void AddReadOnlyRow(ViewNode node, ICmObject obj, int depth, string disp // time, so composing stays side-effect free and the edit context exists by then. private void AddPluginRow(ViewNode node, ICmObject obj, int depth, IRegionEditorPlugin plugin) { - var editContextAccessor = _editContextAccessor; - var cache = _cache; - var services = _services; - // D4: a service-aware plugin (the launcher lane) gets the host services through the - // five-argument overload; classic plugins keep the original contract. - Func factory = plugin is IServiceAwareRegionEditorPlugin serviceAware - ? () => serviceAware.BuildControl(obj, node, editContextAccessor?.Invoke(), cache, services) - : (Func)(() => plugin.BuildControl(obj, node, editContextAccessor?.Invoke(), cache)); + // Review task 13: ONE plugin contract — the build context bundles everything a + // plugin can need (object, node, deferred edit-context accessor, cache, optional + // host services); the former IServiceAwareRegionEditorPlugin type test is gone. + var context = new RegionEditorBuildContext(obj, node, _editContextAccessor, _cache, _services); + Func factory = () => plugin.BuildControl(context); Fields.Add(new LexicalEditRegionField(StableId(node, obj), Localize(node.Label) ?? node.Field, node.Field, node.WritingSystem, RegionFieldKind.Custom, node.EditorClassification, node.AutomationId, node.LocalizationKey, node.Routing, null, null, null, @@ -1549,6 +1699,13 @@ private GhostCreation ResolveGhostCreation(ViewNode node, ICmObject obj) catch (Exception) { ghostFlid = 0; } } + // Review task 3: with no resolvable ghost field AND no init method, typing could + // only MakeNewObject a bare object while the typed text silently vanished (nothing + // receives the string). No shipped layout authors such a ghost; render the prompt + // NON-editable (null) instead of destroying input on the first keystroke. + if (ghostFlid == 0 && string.IsNullOrEmpty(node.GhostInitMethod)) + return null; + var ws = ResolveGhostWs(node.GhostWs); if (ws == null) return null; @@ -1579,11 +1736,9 @@ private GhostCreation ResolveGhostCreation(ViewNode node, ICmObject obj) } if (ghostFlid != 0) { - var tss = TsStringUtils.MakeString(value ?? string.Empty, ws.Handle); - if ((CellarPropertyType)_mdc.GetFieldType(ghostFlid) == CellarPropertyType.String) - _sda.SetString(createdHvo, ghostFlid, tss); - else - _sda.SetMultiStringAlt(createdHvo, ghostFlid, ws.Handle, tss); + // Task 11: the shared 3-way text write dispatch. + WriteTextProp(createdHvo, ghostFlid, ws.Handle, + (CellarPropertyType)_mdc.GetFieldType(ghostFlid), value); } // B2: invoke the layout's ghostInitMethod by reflection on the newly created // object, after the typed text lands — exactly GhostStringSliceView. @@ -1792,7 +1947,7 @@ internal static IReadOnlyList ResolveWritingSystems /// internal static ViewDefinitionModel CompileForObject(LcmCache cache, ICmObject obj, string layoutName) { - var sources = Sources.Value; + var sources = GetSources(); if (sources == null) return null; @@ -1849,7 +2004,14 @@ private static CompilerSources LoadSources() var partsDirectory = FwDirectoryFinder.GetCodeSubDirectory(@"Language Explorer\Configuration\Parts"); var partsXml = LayoutSourceLoader.LoadMergedPartsXml(partsDirectory); if (partsXml == null) + { + // Review task 10: never a silent permanent failure — log, fall back to the + // 3-field first slice for THIS compose, and retry next time (GetSources). + SIL.Reporting.Logger.WriteEvent( + "FullEntryRegionComposer: no merged parts XML under '" + partsDirectory + + "'; falling back to the first slice (will retry on the next compose)."); return null; + } var layoutFiles = LayoutSourceLoader.LoadLayoutFiles(partsDirectory); return new CompilerSources @@ -1858,8 +2020,13 @@ private static CompilerSources LoadSources() LayoutIndex = LayoutSourceLoader.IndexLayouts(layoutFiles) }; } - catch (Exception) + catch (Exception e) { + // Review task 10: never a silent permanent failure — log, fall back to the + // 3-field first slice for THIS compose, and retry next time (GetSources). + SIL.Reporting.Logger.WriteError( + "FullEntryRegionComposer: failed to load layout sources; " + + "falling back to the first slice for this compose.", e); return null; } } diff --git a/Src/xWorks/LegacyDialogLauncher.cs b/Src/xWorks/LegacyDialogLauncher.cs index b6deb8c757..0b13b68192 100644 --- a/Src/xWorks/LegacyDialogLauncher.cs +++ b/Src/xWorks/LegacyDialogLauncher.cs @@ -66,7 +66,7 @@ public sealed class RegionEditorServices /// opening its own UOW while the fence holds the write lock would throw, the same hazard the /// undo guard exists for. ///
- public sealed class WinFormsLegacyDialogLauncher : ILegacyDialogLauncher + public sealed class WinFormsLegacyDialogLauncher : ILegacyDialogLauncher, IDisposable { private const string LexTextControlsDll = "LexTextControls.dll"; private const string MsaDialogClass = "SIL.FieldWorks.LexText.Controls.MsaInflectionFeatureListDlg"; @@ -80,6 +80,11 @@ public sealed class WinFormsLegacyDialogLauncher : ILegacyDialogLauncher private readonly PropertyTable _propertyTable; private readonly Func _ownerForm; private readonly Action _beforeLaunch; + // SoundPlayer.Play() streams asynchronously: the player must outlive the call or playback + // is cut off (the legacy AudioVisualLauncher keeps an m_player field for the same + // lifetime reason). Released when the next play starts; the last one lives with this + // launcher (which the host keeps for its own lifetime). + private System.Media.SoundPlayer _player; public WinFormsLegacyDialogLauncher(LcmCache cache, Mediator mediator, PropertyTable propertyTable, Func ownerForm, Action beforeLaunch = null) @@ -91,6 +96,13 @@ public WinFormsLegacyDialogLauncher(LcmCache cache, Mediator mediator, _beforeLaunch = beforeLaunch; } + /// Releases the held media player (legacy AudioVisualLauncher.Dispose parity). + public void Dispose() + { + _player?.Dispose(); + _player = null; + } + public bool LaunchFor(ICmObject obj, ViewNode node) { if (obj == null || node == null) @@ -204,7 +216,7 @@ private bool LaunchFeatureDialog(ICmObject obj, ViewNode node, string dialogClas // AudioVisualLauncher.HandleChooser, minus the WinForms slice: SoundPlayer for a real wav // (sniffed by RIFF/WAVE header, like legacy), the OS default app for everything else. - private static bool PlayMedia(ICmObject obj) + private bool PlayMedia(ICmObject obj) { var file = DialogLauncherPlugins.ResolveMediaFile(obj); if (file == null) @@ -218,8 +230,11 @@ private static bool PlayMedia(ICmObject obj) if (IsWavFile(path)) { - using (var player = new System.Media.SoundPlayer(path)) - player.Play(); + // Play() is asynchronous; disposing the player immediately can stop playback. + // Keep it alive in the field until the next play (or the launcher goes away). + _player?.Dispose(); + _player = new System.Media.SoundPlayer(path); + _player.Play(); } else { diff --git a/Src/xWorks/LexicalEditRegionBuilder.cs b/Src/xWorks/LexicalEditRegionBuilder.cs index cf64256b58..76c748f560 100644 --- a/Src/xWorks/LexicalEditRegionBuilder.cs +++ b/Src/xWorks/LexicalEditRegionBuilder.cs @@ -91,33 +91,29 @@ public IReadOnlyList GetValues(ViewNode fieldNode) // Tasks 6.2/6.13 (multi-WS read path): one row per *current* writing system — the same // "all vernacular"/"all analysis" semantics the compiled slice definitions carry — rendered // with the project's per-WS default font so both surfaces show the same record consistently. + // Review task 12: the per-ws row projection is the shared RegionValueFactory recipe (the + // composer uses the same one); only the text reads live here. private IReadOnlyList GetLexemeFormValues() { - var values = new List(); - foreach (var ws in _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems) - { - var text = _entry.LexemeFormOA?.Form?.get_String(ws.Handle)?.Text; - if (string.IsNullOrEmpty(text) && ws.Handle == _cache.DefaultVernWs) - text = _entry.CitationForm.get_String(ws.Handle)?.Text; // legacy fallback, default ws only - values.Add(new RegionWsValue(ws.Abbreviation, text ?? string.Empty, ws.DefaultFontName, 0, - ws.RightToLeftScript, ws.Id)); - } - - return values; + return RegionValueFactory.BuildMultiWsValues( + _cache.ServiceLocator.WritingSystems.CurrentVernacularWritingSystems, ws => + { + var text = _entry.LexemeFormOA?.Form?.get_String(ws.Handle)?.Text; + if (string.IsNullOrEmpty(text) && ws.Handle == _cache.DefaultVernWs) + text = _entry.CitationForm.get_String(ws.Handle)?.Text; // legacy fallback, default ws only + return text; + }); } private IReadOnlyList GetGlossValues() { - var values = new List(); if (_entry.SensesOS.Count == 0) - return values; + return new List(); var gloss = _entry.SensesOS[0].Gloss; - foreach (var ws in _cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems) - values.Add(new RegionWsValue(ws.Abbreviation, gloss.get_String(ws.Handle)?.Text ?? string.Empty, - ws.DefaultFontName, 0, ws.RightToLeftScript, ws.Id)); - - return values; + return RegionValueFactory.BuildMultiWsValues( + _cache.ServiceLocator.WritingSystems.CurrentAnalysisWritingSystems, + ws => gloss.get_String(ws.Handle)?.Text); } /// @@ -149,19 +145,20 @@ public static void ActivateKeyboardForWritingSystem(LcmCache cache, string wsTag /// public IReadOnlyList GetOptions(ViewNode fieldNode) { - var options = new List(); if (fieldNode.Field != MorphTypeField) - return options; + return new List(); // Task 4.10: chooser options come from the project's morph-type possibility list, keyed by // guid, so every project-defined morph type (phrase, clitic, infix, ...) is offered instead // of a hardcoded subset. var morphTypes = _cache.LangProject.LexDbOA?.MorphTypesOA; if (morphTypes == null) - return options; + return new List(); - AddPossibilities(morphTypes.PossibilitiesOS, options); - return options; + // Review task 12: the shared flattener (document order, hierarchy as Depth, and the + // composer's name-fallback rule — this builder's old analysis→vernacular fallback is + // subsumed by ShortName's own legacy resolution; see RegionValueFactory). + return RegionValueFactory.BuildPossibilityOptions(morphTypes, flat: false); } /// @@ -173,18 +170,6 @@ public string GetSelectedOptionKey(ViewNode fieldNode) return _entry.LexemeFormOA?.MorphTypeRA?.Guid.ToString(); } - private static void AddPossibilities(IEnumerable possibilities, List options) - { - foreach (var possibility in possibilities) - { - var name = possibility.Name.BestAnalysisAlternative?.Text; - if (string.IsNullOrEmpty(name)) - name = possibility.Name.BestVernacularAlternative?.Text ?? possibility.Guid.ToString(); - options.Add(new RegionChoiceOption(possibility.Guid.ToString(), name)); - AddPossibilities(possibility.SubPossibilitiesOS, options); - } - } - private string GetLexemeFormText() { var lexemeText = _entry.LexemeFormOA?.Form != null diff --git a/Src/xWorks/RecordEditView.cs b/Src/xWorks/RecordEditView.cs index 25498e5b32..5e75e0f02b 100644 --- a/Src/xWorks/RecordEditView.cs +++ b/Src/xWorks/RecordEditView.cs @@ -23,6 +23,7 @@ using SIL.FieldWorks.Common.RootSites; using SIL.LCModel.Core.KernelInterfaces; using SIL.PlatformUtilities; +using SIL.Reporting; using SIL.Utils; namespace SIL.FieldWorks.XWorks @@ -79,10 +80,17 @@ public class RecordEditView : RecordView, IVwNotifyChange, IFocusablePanePortion // today only the legacy-dialog launcher seam (this view is the sanctioned WinForms // carve-out; the pane itself stays WinForms-free). private RegionEditorServices m_regionEditorServices; - // Settle-on-deactivate hook (review round 2): the undo guard is per-stack and cannot reach - // other windows' undo stacks, so settle when this view's top-level window loses activation. - private EventHandler m_settleOnDeactivate; - private Form m_guardedForm; + // 13.4: the approved baseline-adapter ids — the ONLY routes allowed to drive hidden legacy + // infrastructure while Avalonia is active. Keep in sync with the region manifest's + // allowedAdapters (openspec/changes/lexical-edit-avalonia-migration/region-manifest.md); + // hardcoded here because the manifest is documentation, not yet machine-readable. + internal const string CommandMenuRoutingAdapterId = "command-menu-routing"; + private static readonly string[] ApprovedBaselineAdapters = { CommandMenuRoutingAdapterId }; + // The active-host contract (task 3.10) for the CURRENT surface, kept in sync with every + // m_lexicalEditSurface assignment (SetLexicalEditSurface) from the approved set above. + // Assert sites only pass the adapter id they claim, so an unlisted id actually trips — a + // contract constructed at the assert site from the very id it then asserts could never fail. + private ActiveHostContract m_activeHostContract; // Hybrid companion lane: the real WinForms slices (today the Chorus Messages notes bar) // promoted out of the Avalonia model into the host's companion strip, plus their editor // controls (reparented into the strip, so the slice's Dispose no longer reaches them). @@ -109,7 +117,7 @@ public RecordEditView() protected RecordEditView(DataTree dataEntryForm) { // This must be called before InitializeComponent() - m_lexicalEditSurface = LexicalEditSurface.WinForms; + SetLexicalEditSurface(LexicalEditSurface.WinForms); m_dataEntryForm = dataEntryForm; m_lexicalEditSurfaceFactory = new LexicalEditSurfaceFactory( () => m_dataEntryForm, @@ -156,7 +164,7 @@ public override void Init(Mediator mediator, PropertyTable propertyTable, XmlNod } // If possible make it use the style sheet appropriate for its main window. - m_lexicalEditSurface = ResolveConfiguredLexicalEditSurface(); + SetLexicalEditSurface(ResolveConfiguredLexicalEditSurface()); if (!ShouldUseAvaloniaLexicalEdit) m_dataEntryForm.StyleSheet = FontHeightAdjuster.StyleSheetFromPropertyTable(m_propertyTable); m_fullyInitialized = true; @@ -193,24 +201,7 @@ protected override void Dispose(bool disposing) else if (m_panel != null && m_panel.Controls.Contains(m_dataEntryForm)) m_panel.Controls.Remove(m_dataEntryForm); } - // Teardown order matters: stop the event/notification plumbing FIRST so the - // settle's commit/rollback PropChanged cannot re-enter a dying view, then settle - // (auto-save 14.4 extends to teardown: a valid pending edit commits, invalid rolls - // back), and only then drop the context. - if (m_avaloniaEntryForm != null) - m_avaloniaEntryForm.RegionEditCompleted -= OnAvaloniaRegionEditCompleted; - if (m_guardedForm != null && m_settleOnDeactivate != null) - { - m_guardedForm.Deactivate -= m_settleOnDeactivate; - m_guardedForm = null; - m_settleOnDeactivate = null; - } - m_regionEditContext.DetachUndoGuard(); - m_avaloniaRefreshController?.Dispose(); - m_regionEditContext.Settle(); - m_regionEditContext.Clear(); - TearDownCompanionSlices(); - m_avaloniaEntryForm?.Dispose(); + TearDownAvaloniaSurface(); m_menuHandler?.Dispose(); if (!string.IsNullOrEmpty(m_titleField)) Cache.DomainDataByFlid.RemoveNotification(this); @@ -222,6 +213,42 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + /// + /// Auto-save (14.4): settles any open fenced edit session — commit when validation is + /// clean, roll back otherwise. The holder guards internally (no-op when nothing is open), + /// so this is idempotent and safe to call unconditionally from ANY host path — including + /// while the legacy surface is active, when no fenced session can be open. + /// + private void SettleRegionEdits() + { + m_regionEditContext.Settle(); + } + + /// + /// The ordering-sensitive teardown of the Avalonia surface plumbing — this is the ONE + /// place that ordering lives. Teardown order matters: stop the event/notification + /// plumbing FIRST so the settle's commit/rollback PropChanged cannot re-enter a dying + /// view, then settle (auto-save 14.4 extends to teardown: a valid pending edit commits, + /// invalid rolls back), then drop the context, and only then dispose the companions and + /// the host control itself. + /// + private void TearDownAvaloniaSurface() + { + if (m_avaloniaEntryForm != null) + m_avaloniaEntryForm.RegionEditCompleted -= OnAvaloniaRegionEditCompleted; + m_regionEditContext.DetachDeactivateHook(); + m_regionEditContext.DetachUndoGuard(); + m_avaloniaRefreshController?.Dispose(); + SettleRegionEdits(); + m_regionEditContext.Clear(); + TearDownCompanionSlices(); + // The launcher may hold the last media player alive (legacy parity); release it with + // the surface that handed it to plugins. + (m_regionEditorServices?.LegacyDialogLauncher as IDisposable)?.Dispose(); + m_regionEditorServices = null; + m_avaloniaEntryForm?.Dispose(); + } + #endregion // Construction and Removal #region Message Handlers @@ -282,13 +309,11 @@ public override bool PrepareToGoAway() { CheckDisposed(); - if (ShouldUseAvaloniaLexicalEdit) - { - // Auto-save (14.4): leaving the tool/area settles any open fenced session the same - // way legacy slices save as the user moves on. - m_regionEditContext.Settle(); - } - else if (m_dataEntryForm != null) + // Auto-save (14.4): leaving the tool/area settles any open fenced session the same way + // legacy slices save as the user moves on. Unconditional (the helper no-ops when no + // session is open), so a session that survived a surface flip still settles safely. + SettleRegionEdits(); + if (!ShouldUseAvaloniaLexicalEdit && m_dataEntryForm != null) { m_dataEntryForm.PrepareToGoAway(); } @@ -327,12 +352,11 @@ public void OnPropertyChanged(string name) if (newSurface == m_lexicalEditSurface) return; - // Settle any open fenced session BEFORE flipping the surface: ShowRecord's settle (and - // ShowRecordOnIdle's) is gated on ShouldUseAvaloniaLexicalEdit, which is already false - // once m_lexicalEditSurface changes — without this, flipping UIMode mid-edit would let - // Clerk.SaveOnChangeRecord force-commit invalid staged state (review round 2). - m_regionEditContext.Settle(); - m_lexicalEditSurface = newSurface; + // Settle any open fenced session BEFORE flipping the surface — without this, flipping + // UIMode mid-edit would let Clerk.SaveOnChangeRecord force-commit invalid staged state + // (review round 2). + SettleRegionEdits(); + SetLexicalEditSurface(newSurface); ShowRecord(new RecordNavigationInfo(Clerk, Clerk.SuppressSaveOnChangeRecord, false, true)); } @@ -409,8 +433,8 @@ bool ShowRecordOnIdle(object parameter) // Auto-save (14.4) must run BEFORE the clerk's save-on-change-record: // RecordClerk.SaveOnChangeRecord force-EndUndoTasks any open undo task wholesale // (LT-16673), which would commit invalid staged state past the validation gate. - if (ShouldUseAvaloniaLexicalEdit) - m_regionEditContext.Settle(); + // Unconditional: a no-op while legacy is active (no fenced session can be open). + SettleRegionEdits(); bool oldSuppressSaveOnChangeRecord = Clerk.SuppressSaveOnChangeRecord; Clerk.SuppressSaveOnChangeRecord = rni.SuppressSaveOnChangeRecord; PrepCacheForNewRecord(); @@ -600,7 +624,9 @@ private void EnsureAvaloniaRefreshController() () => m_regionEditContext.Current?.IsOpen == true, RefreshAvaloniaRegion, new RefreshCoordinator(), - ScheduleOnUiThread); + ScheduleOnUiThread, + // This lexical host's relevance rule; the controller itself stays host-agnostic. + changed => IsChangeWithinEntry(changed, Clerk?.CurrentObject)); // Global Undo/Redo while a fenced session is open would re-enter the UOW write lock // (LockRecursionException); the guard settles the pending edit instead. m_regionEditContext.AttachUndoGuard(Cache.ActionHandlerAccessor); @@ -608,13 +634,21 @@ private void EnsureAvaloniaRefreshController() // so Ctrl+Z in another window while this one holds an open session would still re-enter // the write lock. Mitigate by settling whenever this view's top-level window deactivates // (the user must focus another window before they can undo there). - var form = FindForm(); - if (form != null) - { - m_settleOnDeactivate = (s, e) => m_regionEditContext.Settle(); - form.Deactivate += m_settleOnDeactivate; - m_guardedForm = form; - } + m_regionEditContext.AttachDeactivateHook(FindForm()); + } + + /// + /// The lexical host's refresh relevance (task 3.15): a change is relevant when the changed + /// object is, or is owned by, the entry on display. This is the predicate the host injects + /// into ; static and internal so it is + /// unit-testable without a live view. + /// + internal static bool IsChangeWithinEntry(ICmObject changed, ICmObject current) + { + if (changed == null || current == null) + return false; + var owningEntry = changed as ILexEntry ?? changed.OwnerOfClass(); + return owningEntry != null && owningEntry.Hvo == current.Hvo; } // UI-thread deferral for the controller's coalesced refresh queue: posting to the message @@ -625,9 +659,22 @@ private void ScheduleOnUiThread(Action runner) if (IsDisposed) return; if (IsHandleCreated) - BeginInvoke(runner); + { + try + { + BeginInvoke(runner); + } + catch (InvalidOperationException) + { + // Teardown race: the handle can die between the IsHandleCreated check and the + // post, and BeginInvoke then throws. The view is going away, so drop the + // refresh rather than rethrow into the LCModel PropChanged loop that asked. + } + } else + { runner(); + } } /// @@ -640,7 +687,7 @@ private void ShowAvaloniaEntry(ICmObject obj) // Auto-save (14.4): a session still open from the previous record/edit settles before // the region is replaced (commit when valid, roll back when not) — the same policy // every host path shares; Replace's cancel-on-displace stays the safety net. - m_regionEditContext.Settle(); + SettleRegionEdits(); // 13.4 adapter hygiene: the hidden command-routing DataTree must never answer mediator // commands for a PREVIOUS record — reset it whenever the shown record changes; the next @@ -681,7 +728,9 @@ private void ShowAvaloniaEntry(ICmObject obj) } catch (Exception e) { - Debug.WriteLine("Full-entry composition failed; falling back to the first slice: " + e); + // The user silently gets the fixed first-slice view instead of the full entry; + // that degradation must be diagnosable from the log, not just a debugger. + Logger.WriteError("Full-entry composition failed; falling back to the first slice.", e); } if (region == null) @@ -724,7 +773,7 @@ private RegionEditorServices EnsureRegionEditorServices() m_regionEditorServices = new RegionEditorServices { LegacyDialogLauncher = new WinFormsLegacyDialogLauncher(Cache, m_mediator, - m_propertyTable, FindForm, () => m_regionEditContext.Settle()) + m_propertyTable, FindForm, SettleRegionEdits) }; } return m_regionEditorServices; @@ -829,7 +878,8 @@ private void OnRegionMenuRequested(RegionMenuRequest request) } catch (Exception adapterError) { - Debug.WriteLine("Region menu command adapter failed: " + adapterError); + Logger.WriteError("Region menu command adapter failed; menu items that need " + + "the hidden colleague chain will be disabled.", adapterError); } var ids = new List(); @@ -868,8 +918,8 @@ private void OnRegionMenuRequested(RegionMenuRequest request) } catch (Exception nativeMenuError) { - Debug.WriteLine("Avalonia-native menu failed; falling back to the adapter menu: " - + nativeMenuError); + Logger.WriteError("Avalonia-native menu failed; falling back to the adapter menu.", + nativeMenuError); } window.ShowContextMenu(idArray, @@ -877,7 +927,7 @@ private void OnRegionMenuRequested(RegionMenuRequest request) } catch (Exception e) { - Debug.WriteLine("Region context menu failed: " + e); + Logger.WriteError("Region context menu failed.", e); } } @@ -894,14 +944,14 @@ private void OnRegionLinkRequested(RegionLinkRequest request) { try { - m_regionEditContext.Settle(); + SettleRegionEdits(); #pragma warning disable 618 // legacy parity: ReallySimpleListChooser.HandleAnyJump posts the same way m_mediator.PostMessage("FollowLink", BuildFollowLinkArgs(request)); #pragma warning restore 618 } catch (Exception e) { - Debug.WriteLine("Region chooser link jump failed: " + e); + Logger.WriteError("Region chooser link jump failed.", e); } } @@ -926,9 +976,11 @@ internal static FwLinkArgs BuildFollowLinkArgs(RegionLinkRequest request) private void EnsureMenuCommandAdapter(int targetHvo) { // The active-host contract (3.10) is enforced, not just documented: driving the hidden - // legacy DataTree is legal only through this approved baseline adapter. - ActiveHostContract.ForAvalonia("command-menu-routing") - .AssertLegacyDataTreeDriveAllowed("command-menu-routing"); + // legacy DataTree is legal only through an adapter id the host's contract lists. The + // contract was built from ApprovedBaselineAdapters when the surface activated; this + // site only claims its own id (the fallback covers a menu raised before activation). + (m_activeHostContract ?? ActiveHostContract.ForAvalonia(ApprovedBaselineAdapters)) + .AssertLegacyDataTreeDriveAllowed(CommandMenuRoutingAdapterId); if (!m_legacySurfaceInitialized) { @@ -1010,8 +1062,34 @@ private void OnAvaloniaRegionEditCompleted(object sender, EventArgs e) } } + // Assigns the resolved surface and keeps the active-host contract (task 3.10) in lockstep, + // so the contract reflects the resolved surface from construction on — not only after the + // first activation (which a headless host may never reach). + private void SetLexicalEditSurface(LexicalEditSurface surface) + { + m_lexicalEditSurface = surface; + SyncActiveHostContract(); + } + + private void SyncActiveHostContract() + { + var kind = ShouldUseAvaloniaLexicalEdit + ? LexicalEditSurfaceKind.Avalonia + : LexicalEditSurfaceKind.Legacy; + if (m_activeHostContract == null || m_activeHostContract.ActiveSurface != kind) + { + m_activeHostContract = ShouldUseAvaloniaLexicalEdit + ? ActiveHostContract.ForAvalonia(ApprovedBaselineAdapters) + : ActiveHostContract.ForLegacy(); + } + } + private void EnsureAvaloniaSurfaceActive() { + // Re-sync the contract BEFORE realizing the surface so it reflects the activation even + // if surface construction fails part-way. + SyncActiveHostContract(); + if (m_avaloniaEntryForm == null) EnsureAvaloniaSurfaceInitialized(); @@ -1022,6 +1100,8 @@ private void EnsureAvaloniaSurfaceActive() private void EnsureLegacySurfaceVisible() { + SyncActiveHostContract(); + AttachLegacySurfaceToPanel(); // The legacy DataTree builds its own MessageSlice/ChorusSystem; release the Avalonia // lane's companions so two Chorus systems never sit on the project at once. @@ -1087,7 +1167,7 @@ protected override void SetupDataContext() // InitBase() calls SetupDataContext() before RecordEditView.Init() resolves the surface, so // resolve it here too — otherwise the first surface initialization would use the ctor default // (WinForms) and the active-host contract (task 3.10) would be violated for an Avalonia start. - m_lexicalEditSurface = ResolveConfiguredLexicalEditSurface(); + SetLexicalEditSurface(ResolveConfiguredLexicalEditSurface()); // Surface-agnostic: the record list bar must update regardless of which detail surface is active. Clerk.UpdateRecordTreeBarIfNeeded(); @@ -1141,7 +1221,8 @@ private void SetupSliceFilter() } catch (Exception e) { - throw new ConfigurationException ("Could not load the filter.", m_configurationParameters, e); + // SIL.Utils-qualified: the SIL.Reporting using (Logger) also exports a ConfigurationException. + throw new SIL.Utils.ConfigurationException ("Could not load the filter.", m_configurationParameters, e); } } diff --git a/Src/xWorks/RegionEditContextHolder.cs b/Src/xWorks/RegionEditContextHolder.cs index 6bff077d9c..f39deab4b0 100644 --- a/Src/xWorks/RegionEditContextHolder.cs +++ b/Src/xWorks/RegionEditContextHolder.cs @@ -2,7 +2,9 @@ // This software is licensed under the LGPL, version 2.1 or later // (http://www.gnu.org/licenses/lgpl-2.1.html) +using System; using System.ComponentModel; +using System.Windows.Forms; using SIL.FieldWorks.Common.FwAvalonia.Region; using SIL.LCModel.Application; using SIL.LCModel.Core.KernelInterfaces; @@ -28,6 +30,8 @@ namespace SIL.FieldWorks.XWorks public sealed class RegionEditContextHolder { private IActionHandlerExtensions m_undoHook; + private Form m_deactivateForm; + private EventHandler m_settleOnDeactivate; /// The context currently bound to the shown region, or null. public IRegionEditContext Current { get; private set; } @@ -100,6 +104,32 @@ public void DetachUndoGuard() m_undoHook = null; } + /// + /// Settles whenever the given top-level window deactivates. The undo guard is per-stack + /// and cannot reach other windows' undo stacks, so an open session must close before the + /// user can focus another window and undo there (re-entering the UOW write lock). + /// Detaches any previously attached form first; a null form is a no-op. + /// + public void AttachDeactivateHook(Form form) + { + DetachDeactivateHook(); + if (form == null) + return; + m_settleOnDeactivate = (sender, e) => Settle(); + form.Deactivate += m_settleOnDeactivate; + m_deactivateForm = form; + } + + /// Stops settling on window deactivation. Safe to call when not attached. + public void DetachDeactivateHook() + { + if (m_deactivateForm == null) + return; + m_deactivateForm.Deactivate -= m_settleOnDeactivate; + m_deactivateForm = null; + m_settleOnDeactivate = null; + } + private void OnDoingUndoOrRedo(CancelEventArgs e) { if (Current?.IsOpen != true) diff --git a/Src/xWorks/RegionEditorPlugins.cs b/Src/xWorks/RegionEditorPlugins.cs index 54e88228c2..8916319663 100644 --- a/Src/xWorks/RegionEditorPlugins.cs +++ b/Src/xWorks/RegionEditorPlugins.cs @@ -30,25 +30,50 @@ public interface IRegionEditorPlugin /// /// Builds the Avalonia control that replaces the legacy slice for one composed row. Invoked - /// lazily by the view (never during compose); the composer hands the region's own edit - /// context so plugin edits ride the same fenced session as every other row. + /// lazily by the view (never during compose); the context carries the region's own edit + /// context so plugin edits ride the same fenced session as every other row, plus the + /// optional host services (review task 13: the former IServiceAwareRegionEditorPlugin + /// marker interface and its five-argument overload collapsed into this ONE contract). /// - Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache); + Control BuildControl(RegionEditorBuildContext context); } /// - /// winforms-free-lexeme-editor.md D4 — the back-compatible extension of - /// for plugins that need host-injected services (today the - /// seam): the composer calls the five-argument overload - /// when the plugin implements this interface, passing whatever - /// the host supplied to Compose (null when none — services are always optional). Existing - /// plugins keep the original four-argument contract untouched. + /// Review task 13 — everything the composer hands a plugin factory, bundled into one contract: + /// the row's object and typed node, the region's edit context (resolved lazily through the + /// composer's deferred accessor — the context object is created during compose, BEFORE the + /// edit context exists; plugin factories run at render time, after), the cache, and the + /// host-injected (D4; null when the host supplies none — + /// services are always optional and plugins must tolerate null). /// - public interface IServiceAwareRegionEditorPlugin : IRegionEditorPlugin + public sealed class RegionEditorBuildContext { - /// As , plus the host services (may be null). - Control BuildControl(ICmObject obj, ViewNode node, IRegionEditContext editContext, LcmCache cache, - RegionEditorServices services); + private readonly Func _editContextAccessor; + + public RegionEditorBuildContext(ICmObject target, ViewNode node, + Func editContextAccessor, LcmCache cache, + RegionEditorServices services = null) + { + Target = target; + Node = node; + _editContextAccessor = editContextAccessor; + Cache = cache; + Services = services; + } + + /// The composed row's own object (the slice's object in legacy terms). + public ICmObject Target { get; } + + /// The row's typed view node (layout identity, field, label, menu bindings). + public ViewNode Node { get; } + + /// The region's edit context, resolved on read (null until the region composed). + public IRegionEditContext EditContext => _editContextAccessor?.Invoke(); + + public LcmCache Cache { get; } + + /// Host-injected services (the legacy-dialog launcher seam); may be null. + public RegionEditorServices Services { get; } } /// diff --git a/Src/xWorks/RegionValueFactory.cs b/Src/xWorks/RegionValueFactory.cs new file mode 100644 index 0000000000..de6660c251 --- /dev/null +++ b/Src/xWorks/RegionValueFactory.cs @@ -0,0 +1,72 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using System.Collections.Generic; +using SIL.FieldWorks.Common.FwAvalonia.Region; +using SIL.LCModel; +using SIL.LCModel.Core.WritingSystems; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Review task 12 — the ONE home for the two value-projection recipes that + /// and had each + /// grown separately: the per-writing-system value rows and the possibility-list option + /// flattening. Sharing them here is what keeps the two surfaces from drifting — they HAD + /// drifted: the builder's option-name fallback walked analysis → vernacular while the + /// composer's walked analysis → ShortName (see for the + /// deliberate resolution). + /// + internal static class RegionValueFactory + { + /// + /// One per writing system, in list order, carrying the + /// project's per-ws display metadata (abbreviation, default font, RTL, IETF tag); + /// supplies each alternative's text (null reads as empty). + /// + internal static IReadOnlyList BuildMultiWsValues( + IEnumerable systems, + Func readText, + double fontSize = 0, bool boldEmphasis = false) + { + var values = new List(); + foreach (var ws in systems) + { + values.Add(new RegionWsValue(ws.Abbreviation, readText(ws) ?? string.Empty, + ws.DefaultFontName, fontSize, ws.RightToLeftScript, ws.Id, boldEmphasis)); + } + return values; + } + + /// + /// B8/B7: walks a possibility list's tree in document order (parent before children) into + /// chooser options, hierarchy carried as — exactly + /// the indented tree the legacy chooser shows. (a chooserInfo + /// "FlatList" guicontrol spec, e.g. PeopleFlatList) keeps the order but suppresses the + /// hierarchy, like the legacy flat chooser. Option names use the composer's fallback rule + /// — Name.BestAnalysisAlternative, then ShortName, then the guid — chosen over the + /// builder's old explicit analysis→vernacular walk because CmPossibility.ShortName + /// already performs the legacy best-analysis-then-vernacular resolution itself + /// (ShortNameTSS), so the vernacular fallback is subsumed, not lost. + /// + internal static IReadOnlyList BuildPossibilityOptions( + ICmPossibilityList list, bool flat) + { + var options = new List(); + void Add(ICmPossibility possibility, int depth) + { + options.Add(new RegionChoiceOption(possibility.Guid.ToString(), + possibility.Name.BestAnalysisAlternative?.Text ?? possibility.ShortName ?? possibility.Guid.ToString(), + flat ? 0 : depth)); + foreach (var sub in possibility.SubPossibilitiesOS) + Add(sub, depth + 1); + } + + foreach (var possibility in list.PossibilitiesOS) + Add(possibility, 0); + return options; + } + } +} diff --git a/Src/xWorks/XCoreMenuBridge.cs b/Src/xWorks/XCoreMenuBridge.cs index 252ac5afa9..4bac504772 100644 --- a/Src/xWorks/XCoreMenuBridge.cs +++ b/Src/xWorks/XCoreMenuBridge.cs @@ -71,9 +71,17 @@ private static List Convert(ChoiceGroup group) return items; } - // xCore labels mark the accelerator with '_' (WinForms '&'); Avalonia headers show it raw. + // xCore labels mark the accelerator with a single '_' before the mnemonic character (the + // WinForms adapters translate it: label.Replace("_", "&")); Avalonia headers show the text + // raw, so strip only that first marker — any later underscore is literal label content + // (e.g. a user-defined item name) and must survive. private static string StripAccelerator(string text) - => (text ?? string.Empty).Replace("_", string.Empty); + { + if (string.IsNullOrEmpty(text)) + return string.Empty; + var marker = text.IndexOf('_'); + return marker < 0 ? text : text.Remove(marker, 1); + } // Hidden items can strand separators at the edges or double them up. private static void TrimSeparators(List items) diff --git a/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs b/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs index 0ff22ca7d8..79f03a040d 100644 --- a/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs +++ b/Src/xWorks/xWorksTests/ChorusNotesContractTests.cs @@ -264,7 +264,7 @@ public void ExternalChangeToThePrimaryFile_RaisesNotesChanged() WritePrimaryNotesFile(LegacyAnnotationXml); // an S/R pulling a note from a teammate Assert.That(notified.Wait(TimeSpan.FromSeconds(10)), Is.True, - "the repository's FileSystemWatcher must surface NotifyOfStaleList as NotesChanged"); + "the store's own debounced file watcher must surface an external write as NotesChanged"); } } diff --git a/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs b/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs index 9b5964aeae..e6d79869fb 100644 --- a/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs +++ b/Src/xWorks/xWorksTests/DialogLauncherPluginTests.cs @@ -60,6 +60,11 @@ private static ViewNode LauncherNode(string field, string legacyClassName, strin false, null, Array.Empty(), customEditorClass: legacyClassName, customEditorAssembly: "LexEdDll.dll"); + // Task 13: the one plugin contract takes the bundled build context (services optional). + private RegionEditorBuildContext Ctx(ICmObject obj, ViewNode node, + RegionEditorServices services = null) + => new RegionEditorBuildContext(obj, node, () => null, Cache, services); + private IMoStemMsa MakeStemMsaWithFeatures(out IFsFeatStruc fs) { IMoStemMsa msa = null; @@ -106,7 +111,7 @@ public void LauncherPlugin_WithServices_BuildsRowWiredToTheSeam_WithTheRightObje var services = new RegionEditorServices { LegacyDialogLauncher = fake }; var control = DialogLauncherPlugins.CreateAudioVisual() - .BuildControl(media, node, null, Cache, services); + .BuildControl(Ctx(media, node, services)); Assert.That(control, Is.InstanceOf()); var row = (FwDialogLauncherField)control; @@ -126,14 +131,13 @@ public void LauncherPlugin_WithoutServices_RendersTheRowDisabled_AndLaunchIsANoO var media = MakePronunciationMedia(null); var node = LauncherNode("MediaFile", DialogLauncherPlugins.AudioVisualSliceClassName, "Media File"); - // Both the four-argument (classic) and five-argument (null services) paths degrade the - // same way: value renders, button disabled. + // Null services and services WITHOUT a launcher degrade the same way: value renders, + // button disabled (task 13: one contract, services optional inside the context). var plugin = DialogLauncherPlugins.CreateAudioVisual(); foreach (var control in new[] { - plugin.BuildControl(media, node, null, Cache), - plugin.BuildControl(media, node, null, Cache, null), - plugin.BuildControl(media, node, null, Cache, new RegionEditorServices()) + plugin.BuildControl(Ctx(media, node)), + plugin.BuildControl(Ctx(media, node, new RegionEditorServices())) }) { Assert.That(control, Is.InstanceOf()); @@ -153,7 +157,7 @@ public void MsaLauncherPlugin_ValueIsTheFeatureStructureShortName_AndSeamGetsThe var services = new RegionEditorServices { LegacyDialogLauncher = fake }; var row = (FwDialogLauncherField)DialogLauncherPlugins.CreateMsaInflectionFeatures() - .BuildControl(msa, node, null, Cache, services); + .BuildControl(Ctx(msa, node, services)); // MsaInflectionFeatureListDlgLauncherView renders the structure with // CmAnalObjectVc kfragShortName — i.e. the feature structure's ShortName. @@ -209,35 +213,28 @@ public void AudioVisualValueReader_ReadsTheMediaFilePath_AndToleratesAMissingFil "a non-media object degrades to an empty value"); } - private sealed class FakeServiceAwarePlugin : IServiceAwareRegionEditorPlugin + // Task 13: the former IServiceAwareRegionEditorPlugin marker is gone — EVERY plugin sees + // the host services (possibly null) through the one build context. + private sealed class FakeServicesProbePlugin : IRegionEditorPlugin { public RegionEditorServices LastServices; - public int FiveArgCalls; - public int FourArgCalls; + public int BuildCalls; public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; - public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, - IRegionEditContext editContext, LcmCache cache) - { - FourArgCalls++; - return null; - } - - public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, - IRegionEditContext editContext, LcmCache cache, RegionEditorServices services) + public Avalonia.Controls.Control BuildControl(RegionEditorBuildContext context) { - FiveArgCalls++; - LastServices = services; + BuildCalls++; + LastServices = context.Services; return null; } } [Test] - public void Compose_ThreadsHostServicesIntoServiceAwarePluginFactories() + public void Compose_ThreadsHostServicesIntoPluginFactories() { var registry = new RegionEditorPluginRegistry(); - var plugin = new FakeServiceAwarePlugin(); + var plugin = new FakeServicesProbePlugin(); registry.Register(plugin); var services = new RegionEditorServices { LegacyDialogLauncher = new FakeLegacyDialogLauncher() }; @@ -246,24 +243,23 @@ public void Compose_ThreadsHostServicesIntoServiceAwarePluginFactories() var row = composed.Model.Fields.Single(f => f.Kind == RegionFieldKind.Custom); row.ControlFactory(); - Assert.That(plugin.FiveArgCalls, Is.EqualTo(1), - "a service-aware plugin builds through the five-argument overload"); - Assert.That(plugin.FourArgCalls, Is.EqualTo(0)); + Assert.That(plugin.BuildCalls, Is.EqualTo(1), + "the plugin builds through the one context-based contract"); Assert.That(plugin.LastServices, Is.SameAs(services), "the factory closes over the host's own services instance"); } [Test] - public void Compose_WithoutServices_HandsServiceAwarePluginsNull() + public void Compose_WithoutServices_HandsPluginsNullServices() { var registry = new RegionEditorPluginRegistry(); - var plugin = new FakeServiceAwarePlugin(); + var plugin = new FakeServicesProbePlugin(); registry.Register(plugin); var composed = FullEntryRegionComposer.Compose(m_entry, Cache, plugins: registry); composed.Model.Fields.Single(f => f.Kind == RegionFieldKind.Custom).ControlFactory(); - Assert.That(plugin.FiveArgCalls, Is.EqualTo(1)); + Assert.That(plugin.BuildCalls, Is.EqualTo(1)); Assert.That(plugin.LastServices, Is.Null, "services are optional by contract (default null)"); } } diff --git a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs index 84909bc5b7..83c515eed7 100644 --- a/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs +++ b/Src/xWorks/xWorksTests/FullEntryRegionReferenceChooserTests.cs @@ -271,11 +271,15 @@ public void Compose_SenseStatus_AtomicPossibilityReference_IsChooser_AndCommits( "possAtomicReference takes the chooser lane, like the morph type"); Assert.That(status.IsEditable, Is.True); Assert.That(status.SelectedOptionKey, Is.EqualTo(m_statusConfirmed.Guid.ToString())); + // Review task 6: the empty choice leads (the legacy launcher lets the user clear the + // reference), then the field's possibility list in list order (ReferenceTargetOwner). Assert.That(status.Options.Select(o => o.Key), Is.EqualTo(new[] { - m_statusConfirmed.Guid.ToString(), m_statusPending.Guid.ToString() + string.Empty, m_statusConfirmed.Guid.ToString(), m_statusPending.Guid.ToString() }), - "options come from the field's possibility list (ReferenceTargetOwner), in list order"); + "empty option first, then the list's options in list order"); + Assert.That(status.Options[0].Name, Is.Not.Null.And.Not.Empty, + "the empty choice carries the launchers' localized label (ksNullLabel), never blank"); Assert.That(composed.EditContext.TrySetOption(status, m_statusPending.Guid.ToString()), Is.True); composed.EditContext.Commit(); @@ -285,6 +289,43 @@ public void Compose_SenseStatus_AtomicPossibilityReference_IsChooser_AndCommits( Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusConfirmed)); } + // Review task 6: legacy PossibilityAtomicReferenceLauncher.OnLeave commits an emptied box + // as AddItem(null) — i.e. the reference CLEARS. The composed chooser's empty option does + // the same through the fenced session (SetObjProp(hvo, flid, 0)). + [Test] + public void Edit_AtomicChooser_EmptyOption_ClearsTheReference() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => m_sense.StatusRA = m_statusConfirmed); + + var composed = Compose(); + var status = composed.Model.Fields.Single(f => f.Field == "Status" && f.ObjectHvo == m_sense.Hvo); + + Assert.That(composed.EditContext.TrySetOption(status, string.Empty), Is.True, + "the empty option stages a clear"); + composed.EditContext.Commit(); + Assert.That(m_sense.StatusRA, Is.Null, "the reference cleared, like legacy AddItem(null)"); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.StatusRA, Is.EqualTo(m_statusConfirmed), + "the clear is one step on the global undo stack"); + } + + // The morph-type chooser must NOT gain the empty choice: legacy + // MorphTypeAtomicLauncher.AllowEmptyItem is false (a form always has a morph type). + [Test] + public void Compose_MorphTypeChooser_OffersNoEmptyOption() + { + var composed = Compose(); + var morphType = composed.Model.Fields + .Single(f => f.Field == "MorphType" && f.Kind == RegionFieldKind.Chooser); + + Assert.That(morphType.Options.Select(o => o.Key), Has.None.EqualTo(string.Empty), + "MorphTypeAtomicLauncher.AllowEmptyItem == false — no empty choice here"); + Assert.That(composed.EditContext.TrySetOption(morphType, string.Empty), Is.False, + "an empty key must not clear the morph type"); + } + [Test] public void Edit_AtomicChooser_RejectsKeysOutsideTheList_WithoutOpeningASession() { diff --git a/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs b/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs index 282808901d..813d9e890b 100644 --- a/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs +++ b/Src/xWorks/xWorksTests/LexemeEditorBurnDownTests.cs @@ -198,8 +198,7 @@ public StubPlugin(string legacyClassName) public string LegacyClassName { get; } - public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, - IRegionEditContext editContext, LcmCache cache) => null; + public Avalonia.Controls.Control BuildControl(RegionEditorBuildContext context) => null; } [Test] @@ -282,14 +281,13 @@ private sealed class FakeMessagesPlugin : IRegionEditorPlugin public string LegacyClassName => AvaloniaCompanionSlices.MessageSliceClassName; - public Avalonia.Controls.Control BuildControl(ICmObject obj, ViewNode node, - IRegionEditContext editContext, LcmCache cache) + public Avalonia.Controls.Control BuildControl(RegionEditorBuildContext context) { BuildCalls++; - LastObject = obj; - LastNode = node; - LastEditContext = editContext; - LastCache = cache; + LastObject = context.Target; + LastNode = context.Node; + LastEditContext = context.EditContext; + LastCache = context.Cache; return null; // never rendered in this fixture; the view's guard lane covers null } } diff --git a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs index 167e56f59f..f478a02e18 100644 --- a/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs +++ b/Src/xWorks/xWorksTests/LexicalEditRegionEditingTests.cs @@ -11,6 +11,7 @@ using SIL.LCModel; using SIL.LCModel.Application; using SIL.LCModel.Core.Cellar; +using SIL.LCModel.Core.KernelInterfaces; using SIL.LCModel.Core.Text; using SIL.LCModel.Core.WritingSystems; using SIL.LCModel.DomainServices; @@ -253,8 +254,12 @@ public void RefreshController_HoldsRefreshWhileEditing_DeliversOnCompletion() TsStringUtils.MakeString("raced", Cache.DefaultVernWs))); Assert.That(refreshes, Is.EqualTo(0), "refreshes are held while the surface's own session is open"); + // The production completion path (RecordEditView.OnAvaloniaRegionEditCompleted): + // drop the held delivery and request ONE coalesced refresh covering both the + // completed edit and anything held during it. editing = false; - controller.NotifyEditCompleted(); + controller.DiscardHeldRefresh(); + controller.RequestRefresh(); Assert.That(refreshes, Is.EqualTo(1), "the held refresh is delivered once on edit completion"); } } @@ -387,6 +392,72 @@ public void Compose_HidesEmptyIfDataFields_AndShowsThemOnceFilled() "the field appears once it has data"); } + // Review task 2: legacy enumComboBox is a CLOSED combo over the layout's stringList + // labels (EnumComboSlice) — it must never compose as a free-form editor that could + // persist invalid enum values. Until the importer carries the stringList ids, the row is + // READ-ONLY showing the raw stored value. The Allomorph Status slice + // (Morphology.fwlayout AsLexemeFormBasic over MoForm-Detail-AllomorphStatus) is the + // enumComboBox the entry walk reaches. + [Test] + public void Compose_EnumComboBox_ComposesReadOnly_NeverAFreeFormEditor() + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.LexemeFormOA.IsAbstract = true); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var row = composed.Model.Fields.Single(f => f.Field == "IsAbstract" + && f.ObjectHvo == m_entry.LexemeFormOA.Hvo); + + Assert.That(row.Kind, Is.EqualTo(RegionFieldKind.Text), "a formatted value row"); + Assert.That(row.IsEditable, Is.False, + "read-only until the stringList option chooser lands — never a raw int editor"); + Assert.That(row.Values.Single().Value, Is.EqualTo("1"), + "the raw stored value renders (best effort without the stringList labels)"); + Assert.That(composed.EditContext.TrySetText(row, "", "2"), Is.False, + "no setter is registered — nothing can persist an invalid enum value"); + } + + // Review task 4: the plain-text setter replaces the WHOLE alternative via MakeString, so + // a row whose current content is rich (multiple runs / props beyond the ws) composes + // READ-ONLY — one keystroke must not flatten embedded writing systems or styles. The + // editable rich-text editor is gated on 6.13. + [Test] + public void Compose_RichAlternative_ComposesReadOnly_SoAKeystrokeCannotFlattenIt() + { + // Plain single-run content stays editable. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("Smith 1999", Cache.DefaultAnalWs))); + var plain = FullEntryRegionComposer.Compose(m_entry, Cache).Model.Fields + .Single(f => f.Field == "Bibliography"); + Assert.That(plain.IsEditable, Is.True, "plain single-run content keeps the text editor"); + + // Now make the alternative rich: two runs in different writing systems. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var bldr = TsStringUtils.MakeIncStrBldr(); + bldr.SetIntPropValues((int)FwTextPropType.ktptWs, + (int)FwTextPropVar.ktpvDefault, Cache.DefaultAnalWs); + bldr.Append("Smith "); + bldr.SetIntPropValues((int)FwTextPropType.ktptWs, + (int)FwTextPropVar.ktpvDefault, Cache.DefaultVernWs); + bldr.Append("1999"); + m_entry.Bibliography.set_String(Cache.DefaultAnalWs, bldr.GetString()); + }); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var rich = composed.Model.Fields.Single(f => f.Field == "Bibliography"); + Assert.That(rich.Values.Any(v => v.Value == "Smith 1999"), Is.True, + "the rich content still displays as text"); + Assert.That(rich.IsEditable, Is.False, + "rich content composes read-only so a plain-text write-back cannot destroy runs"); + Assert.That(composed.EditContext.TrySetText(rich, + Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id, "flattened"), + Is.False, "no setter is registered for the rich row"); + Assert.That(m_entry.Bibliography.get_String(Cache.DefaultAnalWs).RunCount, Is.EqualTo(2), + "the runs survive"); + } + [Test] public void Edit_NestedSecondSenseGloss_CommitsAsOneGlobalUndoStep() { @@ -560,6 +631,14 @@ public void Compose_DuplicateWsAbbreviations_StillComposes_AndEditsRouteByWsTag( [Test] public void Compose_BooleanFields_RenderAsCheckboxKind_AndToggle() { + // Review task 2 made the enumComboBox-backed Allomorph Status row read-only, so this + // test now exercises a REAL checkbox slice: an alternate form's "Is Abstract Form" + // (MoStemAllomorph/Normal, editor="Checkbox", visibility=never -> shows under + // show-hidden) over the persisted IsAbstract boolean flid. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.AlternateFormsOS.Add( + Cache.ServiceLocator.GetInstance().Create())); + var hidden = FullEntryRegionComposer.Compose(m_entry, Cache, showHiddenFields: true); var boolField = hidden.Model.Fields.FirstOrDefault(f => f.Kind == RegionFieldKind.Boolean); Assert.That(boolField, Is.Not.Null, "the entry layout carries at least one checkbox field"); @@ -771,6 +850,29 @@ public void Compose_GhostTranslation_CreatesACmTranslation_TypedFreeByGhostInitM "the new translation is typed Free Translation, like legacy"); } + // Review task 3: a ghost prompt whose layout authors NO ghost field (and no init method) + // must compose NON-editable — before this fix, typing into it called MakeNewObject and + // silently DISCARDED the typed text (nothing existed to receive the string). The shipped + // AlternateForms seq (LexEntryParts.xml:119, no ghost= attribute) is exactly that case. + [Test] + public void Compose_GhostWithoutGhostField_IsNotEditable_SoTypingCannotCreateAndDiscard() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var ghost = composed.Model.Fields.FirstOrDefault(f => + f.Field == "AlternateForms" && f.StableId.EndsWith("/ghost")); + Assert.That(ghost, Is.Not.Null, + "the empty always-visible alternate-forms sequence still renders its prompt row"); + Assert.That(ghost.IsEditable, Is.False, + "no ghost field anywhere to route the text: the prompt must not accept typing"); + + var before = m_entry.AlternateFormsOS.Count; + Assert.That(composed.EditContext.TrySetText(ghost, ghost.Values[0].WsAbbrev, "lost"), + Is.False, "no setter is registered for the non-editable prompt"); + Assert.That(composed.EditContext.IsOpen, Is.False, "nothing staged, no session opened"); + Assert.That(m_entry.AlternateFormsOS.Count, Is.EqualTo(before), + "no bare object was created behind the user's back"); + } + // B3 (xml-retirement-blockers) — conditional display: the real shipped variant/complex-form // divergence. LexEntryRef/Normal's VariantEntryTypes and ComplexEntryTypes parts are // twins (LexEntryParts.xml:1133-1162); exactly one may @@ -1179,6 +1281,84 @@ public void Edit_CustomFields_StageThroughTheFencedSession_AsOneUndoStep() Assert.That(sda.get_StringProp(m_entry.Hvo, m_flidEntrySingle).Text, Is.EqualTo("from Smith")); } + // Review task 5: a plain String prop reads the WHOLE string (get_StringProp), so the + // row's writing system must come from the STRING's own first run when there is content — + // not from the layout's ws= spec — and write-back must use that same ws (legacy + // StringSlice renders the string's own run properties). The layout ws only seeds an + // empty string. + [Test] + public void Compose_StringProp_DerivesTheRowWsFromTheStoredString_AndWritesBackSymmetrically() + { + var vern = Cache.ServiceLocator.WritingSystems.DefaultVernacularWritingSystem; + var sda = Cache.DomainDataByFlid; + // "Source Note" is a kwsAnal-selector String field, but the user typed vernacular. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + sda.SetString(m_entry.Hvo, m_flidEntrySingle, + TsStringUtils.MakeString("vern note", vern.Handle))); + + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var single = composed.Model.Fields.First(f => f.Label == "Source Note"); + Assert.That(single.Values.Single().WsTag, Is.EqualTo(vern.Id), + "the row's ws (abbrev/font/RTL identity) follows the stored string's first run"); + + Assert.That(composed.EditContext.TrySetText(single, vern.Id, "edited note"), Is.True, + "the derived ws is also the row's write-back key"); + composed.EditContext.Commit(); + var stored = sda.get_StringProp(m_entry.Hvo, m_flidEntrySingle); + Assert.That(stored.Text, Is.EqualTo("edited note")); + Assert.That(TsStringUtils.GetWsOfRun(stored, 0), Is.EqualTo(vern.Handle), + "write-back keeps the string's own ws — read and write are symmetric"); + } + + [Test] + public void Compose_EmptyStringProp_FallsBackToTheLayoutWs() + { + ILexEntry bare = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + bare = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + bare.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("gato", Cache.DefaultVernWs)); + }); + + var composed = FullEntryRegionComposer.Compose(bare, Cache); + var single = composed.Model.Fields.First(f => f.Label == "Source Note" && f.ObjectHvo == bare.Hvo); + Assert.That(single.Values.Single().WsTag, + Is.EqualTo(Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id), + "an empty String prop seeds from the field's own ws selector (kwsAnal)"); + } + + // Review task 7: clearing an int box must be SAFE. Legacy IntegerSlice treats a + // non-numeric box — including empty — as invalid on focus loss (warn + restore the + // stored value; Convert.ToInt32("") throws), never committing empty as 0. The composed + // setter mirrors that: empty/whitespace stages NOTHING, and the last successfully staged + // value is what a commit applies. + [Test] + public void Edit_IntegerField_EmptyAndWhitespace_StageNothing() + { + var composed = FullEntryRegionComposer.Compose(m_entry, Cache); + var number = composed.Model.Fields.First(f => f.Label == "Frequency Count"); + var sda = Cache.DomainDataByFlid; + + Assert.That(composed.EditContext.TrySetText(number, "", ""), Is.False, + "clearing the box stages nothing (legacy: invalid, restore)"); + Assert.That(composed.EditContext.TrySetText(number, "", " "), Is.False); + Assert.That(composed.EditContext.TrySetText(number, "", "not-a-number"), Is.False); + Assert.That(composed.EditContext.IsOpen, Is.False, + "rejected values must not leave an empty fence open"); + Assert.That(sda.get_IntProp(m_entry.Hvo, m_flidEntryNumber), Is.EqualTo(42), + "the stored value is untouched"); + + // Clear-then-retype: only the parseable states stage; commit applies the LAST one. + Assert.That(composed.EditContext.TrySetText(number, "", "5"), Is.True); + Assert.That(composed.EditContext.TrySetText(number, "", ""), Is.False, + "a clear after a valid stage still stages nothing"); + Assert.That(composed.EditContext.TrySetText(number, "", "55"), Is.True); + composed.EditContext.Commit(); + Assert.That(sda.get_IntProp(m_entry.Hvo, m_flidEntryNumber), Is.EqualTo(55)); + } + [Test] public void Compose_EmptyCustomFields_StayVisible_LikeLegacyAlwaysVisibility() { diff --git a/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs index 5aaf316614..8a34cbf1b4 100644 --- a/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs +++ b/Src/xWorks/xWorksTests/RecordEditViewActiveHostContractTests.cs @@ -10,6 +10,7 @@ using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Seams; using SIL.FieldWorks.Common.Framework.DetailControls; using SIL.FieldWorks.Common.FwUtils; using SIL.LCModel; @@ -89,6 +90,19 @@ public void AvaloniaActive_DoesNotInitializeOrDriveLegacyDataTree() Assert.That(panel.Controls.Contains(legacyDataTree), Is.False, "The dormant legacy DataTree must not remain parented in the panel while Avalonia is the active surface."); + // The host's contract instance is built from the approved baseline-adapter set when the + // Avalonia surface activates — NOT constructed ad hoc at an assert site from the very + // adapter id it then asserts (which could never fail). An unlisted id must trip it. + var contract = (ActiveHostContract)GetPrivateFieldValue(control, "m_activeHostContract"); + Assert.That(contract, Is.Not.Null, + "Activating the Avalonia surface must build the host's active-host contract."); + Assert.That(contract.ActiveSurface, Is.EqualTo(LexicalEditSurfaceKind.Avalonia)); + Assert.That(() => contract.AssertLegacyDataTreeDriveAllowed(RecordEditView.CommandMenuRoutingAdapterId), + Throws.Nothing, "The approved command-menu-routing baseline adapter stays permitted."); + Assert.That(() => contract.AssertLegacyDataTreeDriveAllowed("unlisted-adapter"), + Throws.InvalidOperationException, + "An adapter id outside the approved set must trip the contract."); + // Note: realizing the Avalonia WinForms-interop host requires a real UI context, which this // headless xWorks harness does not provide, so we do not assert the host was created here. The // FwAvaloniaTests headless suite covers Avalonia surface construction/rendering directly. diff --git a/Src/xWorks/xWorksTests/RegionConsolidationTests.cs b/Src/xWorks/xWorksTests/RegionConsolidationTests.cs new file mode 100644 index 0000000000..6acf9bb03c --- /dev/null +++ b/Src/xWorks/xWorksTests/RegionConsolidationTests.cs @@ -0,0 +1,137 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Seams; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using SIL.LCModel; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// Review consolidation (morph-type GUID knowledge): is the + /// single GUID → kind table, but it lives in the deliberately LCModel-free FwAvalonia seam + /// project, so it mirrors the fixed MoMorphTypeTags model GUIDs as literals. This + /// fixture — in xWorksTests, which references BOTH assemblies — pins every literal to its + /// MoMorphTypeTags constant and pins the stem set to the legacy + /// MorphTypeAtomicLauncher.IsStemType guid list, so neither mirror can drift. + /// + [TestFixture] + public class MorphTypeGuidConsolidationTests + { + private static readonly (Guid Guid, MorphTypeKind Kind)[] ExpectedKinds = + { + (MoMorphTypeTags.kguidMorphRoot, MorphTypeKind.Root), + (MoMorphTypeTags.kguidMorphStem, MorphTypeKind.Stem), + (MoMorphTypeTags.kguidMorphBoundRoot, MorphTypeKind.BoundRoot), + (MoMorphTypeTags.kguidMorphBoundStem, MorphTypeKind.BoundStem), + (MoMorphTypeTags.kguidMorphParticle, MorphTypeKind.Particle), + (MoMorphTypeTags.kguidMorphClitic, MorphTypeKind.Clitic), + (MoMorphTypeTags.kguidMorphProclitic, MorphTypeKind.Proclitic), + (MoMorphTypeTags.kguidMorphEnclitic, MorphTypeKind.Enclitic), + (MoMorphTypeTags.kguidMorphPhrase, MorphTypeKind.Phrase), + (MoMorphTypeTags.kguidMorphDiscontiguousPhrase, MorphTypeKind.DiscontiguousPhrase), + (MoMorphTypeTags.kguidMorphPrefix, MorphTypeKind.Prefix), + (MoMorphTypeTags.kguidMorphSuffix, MorphTypeKind.Suffix), + (MoMorphTypeTags.kguidMorphInfix, MorphTypeKind.Infix), + (MoMorphTypeTags.kguidMorphSimulfix, MorphTypeKind.Simulfix), + (MoMorphTypeTags.kguidMorphSuprafix, MorphTypeKind.Suprafix), + (MoMorphTypeTags.kguidMorphCircumfix, MorphTypeKind.Circumfix), + (MoMorphTypeTags.kguidMorphPrefixingInterfix, MorphTypeKind.PrefixingInterfix), + (MoMorphTypeTags.kguidMorphInfixingInterfix, MorphTypeKind.InfixingInterfix), + (MoMorphTypeTags.kguidMorphSuffixingInterfix, MorphTypeKind.SuffixingInterfix) + }; + + [Test] + public void TryClassify_PinsEveryGuidLiteral_ToItsMoMorphTypeTagsConstant() + { + foreach (var (guid, expectedKind) in ExpectedKinds) + { + Assert.That(MorphTypeSwapLogic.TryClassify(guid, out var kind), Is.True, + $"the seam's table must contain MoMorphTypeTags {expectedKind} ({guid})"); + Assert.That(kind, Is.EqualTo(expectedKind), + $"the seam's literal for {expectedKind} drifted from MoMorphTypeTags"); + } + } + + // The legacy MorphTypeAtomicLauncher.IsStemType guid list (bound root/stem, enclitic, + // particle, proclitic, root, stem, clitic, phrase, discontiguous phrase) — the launcher + // cannot delegate to the seam yet (DetailControls has no FwAvalonia reference), so this + // pins the two sets to each other until the launcher retires with its surface. + [Test] + public void IsStemType_ByGuid_MatchesTheLegacyLauncherSet() + { + var legacyStemGuids = new[] + { + MoMorphTypeTags.kguidMorphBoundRoot, + MoMorphTypeTags.kguidMorphBoundStem, + MoMorphTypeTags.kguidMorphEnclitic, + MoMorphTypeTags.kguidMorphParticle, + MoMorphTypeTags.kguidMorphProclitic, + MoMorphTypeTags.kguidMorphRoot, + MoMorphTypeTags.kguidMorphStem, + MoMorphTypeTags.kguidMorphClitic, + MoMorphTypeTags.kguidMorphPhrase, + MoMorphTypeTags.kguidMorphDiscontiguousPhrase + }; + + foreach (var (guid, kind) in ExpectedKinds) + { + var expected = Array.IndexOf(legacyStemGuids, guid) >= 0; + Assert.That(MorphTypeSwapLogic.IsStemType(guid), Is.EqualTo(expected), + $"{kind} stem/affix classification drifted from MorphTypeAtomicLauncher.IsStemType"); + } + } + + [Test] + public void UnknownGuid_DoesNotClassify_AndIsNotAStemType() + { + var unknown = Guid.NewGuid(); // a user-created morph type has no fixed model guid + Assert.That(MorphTypeSwapLogic.TryClassify(unknown, out _), Is.False); + Assert.That(MorphTypeSwapLogic.IsStemType(unknown), Is.False, + "unknown guids classify as not-a-stem, like the legacy null guard"); + } + } + + /// + /// Review consolidation (editor-kind knowledge): + /// is the ONE editor-string → category table the composer's dispatch switch and + /// LexicalEditRegionMapper.ClassifyKind both consume. These cases pin the categories the + /// two consumers' behavior depends on. + /// + [TestFixture] + public class EditorKindMapRegionCategoryTests + { + [TestCase("multistring", RegionEditorCategory.Text)] + [TestCase("string", RegionEditorCategory.Text)] + [TestCase("MorphTypeAtomicReference", RegionEditorCategory.MorphTypeChooser)] + [TestCase("summary", RegionEditorCategory.Summary)] + [TestCase("lit", RegionEditorCategory.Literal)] + [TestCase("picture", RegionEditorCategory.Picture)] + [TestCase("image", RegionEditorCategory.Picture)] + [TestCase("jtview", RegionEditorCategory.EmbeddedView)] + [TestCase("command", RegionEditorCategory.Command)] + [TestCase("enumComboBox", RegionEditorCategory.EnumCombo)] + [TestCase("possatomicreference", RegionEditorCategory.AtomicReferenceChooser)] + [TestCase("atomicreferencepos", RegionEditorCategory.AtomicReferenceChooser)] + [TestCase("atomicreferenceposdisabled", RegionEditorCategory.AtomicReferenceChooser)] + [TestCase("defaultatomicreference", RegionEditorCategory.AtomicReferenceChooser)] + [TestCase("defaultatomicreferencedisabled", RegionEditorCategory.AtomicReferenceChooser)] + [TestCase(null, RegionEditorCategory.Grouping)] + [TestCase("", RegionEditorCategory.Grouping)] + // Other: consumers refine by CellarPropertyType (composer) or render as text (mapper). + [TestCase("checkbox", RegionEditorCategory.Other)] + [TestCase("gendate", RegionEditorCategory.Other)] + [TestCase("integer", RegionEditorCategory.Other)] + [TestCase("autocustom", RegionEditorCategory.Other)] + [TestCase("no-such-editor", RegionEditorCategory.Other)] + public void ClassifyRegionFieldKind_RoutesLikeTheLegacyDispatch(string rawEditor, + RegionEditorCategory expected) + { + // Case-insensitive, like DataTree's editor.ToLower() dispatch. + Assert.That(EditorKindMap.ClassifyRegionFieldKind(rawEditor), Is.EqualTo(expected)); + } + } +} diff --git a/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs b/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs index 419e0606ea..d31debb888 100644 --- a/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs +++ b/Src/xWorks/xWorksTests/RegionEditGuardAndSchedulingTests.cs @@ -156,6 +156,10 @@ public void UndoGuard_Detached_StopsIntercepting() }); } + // The lexical host's relevance predicate, exactly as RecordEditView injects it. + private Func LexicalRelevance => changed => + RecordEditView.IsChangeWithinEntry(changed, m_entry); + [Test] public void RefreshController_WithAScheduler_CoalescesABurstIntoOneRefresh() { @@ -163,7 +167,7 @@ public void RefreshController_WithAScheduler_CoalescesABurstIntoOneRefresh() var refreshes = 0; using (new AvaloniaRegionRefreshController( Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator(), - schedule: scheduled.Add)) + schedule: scheduled.Add, isRelevant: LexicalRelevance)) { NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => { @@ -305,9 +309,103 @@ public void RefreshController_DiscardHeldRefresh_DropsTheHeldDeliveryWithoutRefr Assert.That(refreshes, Is.EqualTo(0), "the host discards the held delivery when it is about to re-show anyway"); - controller.NotifyEditCompleted(); - Assert.That(refreshes, Is.EqualTo(0), "nothing is left pending after the discard"); + // The completion pair the host actually runs (OnAvaloniaRegionEditCompleted): + // discard + ONE explicit request — one recompose, not the held one plus its own. + controller.RequestRefresh(); + Assert.That(refreshes, Is.EqualTo(1), + "after the discard exactly the one requested re-show runs (nothing was left pending)"); + } + } + + // The held-while-editing scenario through the surviving API: when editing ends, the next + // relevant notification delivers the refresh that was held during the edit (the host's + // explicit completion path is the DiscardHeldRefresh + RequestRefresh pair, above). + // A UOW raises one PropChanged per changed property, so the single-delivery guarantee + // stands on the coalescing scheduler the production host supplies — model it here with a + // queue that runs after the notification burst, exactly like the host's BeginInvoke. + [Test] + public void RefreshController_HeldRefresh_DeliversOnTheNextNotificationAfterEditingEnds() + { + var editing = true; + var refreshes = 0; + var queued = new List(); + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => editing, () => refreshes++, new RefreshCoordinator(), + schedule: a => queued.Add(a))) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("raced", Cache.DefaultVernWs))); + Assert.That(refreshes, Is.EqualTo(0), "held while the surface's own session is open"); + Assert.That(queued, Is.Empty, "a held refresh is pending, not scheduled"); + + editing = false; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("after", Cache.DefaultVernWs))); + Assert.That(queued, Has.Count.EqualTo(1), + "the whole notification burst coalesces into one scheduled delivery"); + + queued[0](); + Assert.That(refreshes, Is.EqualTo(1), + "one delivery covers both the held refresh and the new change"); + } + } + + // Relevance is injected by the host, not hard-coded to ILexEntry inside the controller. + [Test] + public void RefreshController_HostPredicate_CoversObjectsOwnedByTheDisplayedEntry() + { + var refreshes = 0; + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator(), + isRelevant: LexicalRelevance)) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + m_entry.SensesOS[0].Gloss.set_String(Cache.DefaultAnalWs, + TsStringUtils.MakeString("sense-level", Cache.DefaultAnalWs))); + + Assert.That(refreshes, Is.GreaterThanOrEqualTo(1), + "a change to an object OWNED by the displayed entry reaches the surface through the host's predicate"); } } + + [Test] + public void RefreshController_HostPredicate_DecidesRelevanceBeyondTheDisplayedRecord() + { + ILexEntry other = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + other = Cache.ServiceLocator.GetInstance().Create()); + + var refreshes = 0; + using (new AvaloniaRegionRefreshController( + Cache, () => m_entry, () => false, () => refreshes++, new RefreshCoordinator(), + isRelevant: changed => true)) + { + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + other.CitationForm.set_String(Cache.DefaultVernWs, + TsStringUtils.MakeString("unrelated", Cache.DefaultVernWs))); + + Assert.That(refreshes, Is.GreaterThanOrEqualTo(1), + "the injected predicate — not a built-in entry walk — decides relevance for other objects"); + } + } + + // The predicate RecordEditView injects: containment in the displayed entry via the owner + // chain, the behavior the controller used to hard-code. + [Test] + public void IsChangeWithinEntry_WalksTheOwnerChainToTheDisplayedEntry() + { + Assert.That(RecordEditView.IsChangeWithinEntry(m_entry, m_entry), Is.True, + "the entry itself"); + Assert.That(RecordEditView.IsChangeWithinEntry(m_entry.SensesOS[0], m_entry), Is.True, + "a sense owned by the entry"); + Assert.That(RecordEditView.IsChangeWithinEntry(m_entry.LexemeFormOA, m_entry), Is.True, + "the lexeme form owned by the entry"); + Assert.That(RecordEditView.IsChangeWithinEntry(Cache.LangProject, m_entry), Is.False, + "an object outside the entry"); + Assert.That(RecordEditView.IsChangeWithinEntry(null, m_entry), Is.False); + Assert.That(RecordEditView.IsChangeWithinEntry(m_entry, null), Is.False); + } } } From 414fb89d8363f1a86eaf1d2b13123179476dbdff Mon Sep 17 00:00:00 2001 From: John Lambert Date: Fri, 12 Jun 2026 16:42:05 -0400 Subject: [PATCH 09/14] Fix up multiselect from list --- .../skills/fieldworks-avalonia-ui/SKILL.md | 11 + .../FieldWorks.Diagnostics.dev.config | 2 + .../FwAvaloniaTests/FwOptionPickerTests.cs | 123 +++++- .../FwAvaloniaTests/PocLexEntrySliceTests.cs | 7 +- .../PocWinFormsHostControlTests.cs | 40 ++ .../FwAvaloniaTests/RegionEditingTests.cs | 58 ++- .../FwAvaloniaTests/RegionFocusMemoryTests.cs | 109 ++++- .../FwAvalonia/Poc/MorphTypePopupChooser.cs | 38 +- .../FwAvalonia/Poc/PocWinFormsHostControl.cs | 75 +++- .../FwAvalonia/Region/FwFieldControls.cs | 21 +- .../FwAvalonia/Region/FwOptionPicker.cs | 417 +++++++++++++----- .../FwAvalonia/Region/RegionFocusMemory.cs | 78 +++- Src/xWorks/FullEntryRegionComposer.cs | 12 +- .../FullEntryRegionReferenceChooserTests.cs | 22 + 14 files changed, 816 insertions(+), 197 deletions(-) create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/PocWinFormsHostControlTests.cs diff --git a/.claude/skills/fieldworks-avalonia-ui/SKILL.md b/.claude/skills/fieldworks-avalonia-ui/SKILL.md index 976a4d3193..3aac802ae2 100644 --- a/.claude/skills/fieldworks-avalonia-ui/SKILL.md +++ b/.claude/skills/fieldworks-avalonia-ui/SKILL.md @@ -46,6 +46,17 @@ lifetime. Canonical code to imitate: `AutomationProperties.Name` on user-facing controls. - UI logic stays in bindings/view models where practical; avoid logic-heavy code-behind. +- For any Avalonia "select from a list" surface, prefer the shared + `FwOptionPicker` pattern in `Src/Common/FwAvalonia/Region/FwOptionPicker.cs` + (AutoCompleteBox-based, keyboard-safe, search-capable, compact density) + over ad hoc `ListBox` popups or one-off editable selectors. Reach for a raw + `ComboBox` only when the UX explicitly needs an always-visible inline combo + rather than the shared flyout selector. +- Do not fix Avalonia keyboard, focus, filtering, selection, popup, or + rendering bugs by patching `System.Windows.Forms` hosts, WinForms + interop message handling, or other legacy host-only routes unless the + task explicitly targets interop behavior. Default to fixing the issue + inside the Avalonia control tree or Avalonia-owned seams. - Marshal to the UI thread through `IUiScheduler` (or Avalonia dispatcher in non-region code); no hidden `Task.Run`, no sync-over-async. - Keep preview data lightweight unless the change explicitly opts into diff --git a/Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config b/Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config index 60d99fc258..b5b8ae202a 100644 --- a/Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config +++ b/Src/Common/FieldWorks/FieldWorks.Diagnostics.dev.config @@ -13,5 +13,7 @@ + + diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs index 92a1e316d7..9694eaf54d 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/FwOptionPickerTests.cs @@ -22,13 +22,13 @@ namespace FwAvaloniaTests { /// - /// The ONE compact filterable option picker (FwOptionPicker) behind every option dropdown: - /// a selection-filter panel (filter box auto-focused on open, watermarked with the search - /// prompt) over a VIRTUALIZED list capped at the density token height, item spacing pinned - /// to the compact legacy values (never the Fluent defaults), hierarchy preserved by Depth - /// indent. Typing filters live (case-insensitive contains for static options; the host - /// search delegate for search-backed pickers); Down/Up move the highlight, Enter commits - /// the highlighted option (first match by default), Escape dismisses, click commits. + /// The ONE compact filterable option picker (FwOptionPicker) behind every Avalonia select-from- + /// list surface: an AutoCompleteBox-based selector whose embedded search box auto-focuses on + /// open, whose popup list stays virtualized/capped at the density token height, and whose item + /// spacing stays pinned to the compact legacy values (never the Fluent defaults) while preserving + /// possibility-list hierarchy by Depth indent. Static options filter by contains; search-backed + /// pickers populate through the host delegate; Down/Up/Enter/Escape follow the stock selector + /// path while the wrapper preserves FieldWorks commit/dismiss semantics. /// [TestFixture] public class FwOptionPickerTests @@ -57,20 +57,53 @@ private static (FwOptionPicker picker, Window window, List c return (picker, window, committed, dismissed); } - private static void RaiseKey(Control target, Key key) + private static (FwOptionPicker picker, Window window, List committed) ShowStaticWithUnavailable( + params string[] unavailableKeys) + { + var picker = new FwOptionPicker(Tree(), null, "Domains", unavailableKeys); + var committed = new List(); + picker.OptionCommitted += committed.Add; + var window = new Window { Content = picker, Width = 400, Height = 420 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + AvaloniaHeadlessPlatform.ForceRenderTimerTick(); + Dispatcher.UIThread.RunJobs(); + return (picker, window, committed); + } + + private static void RaiseKey(Control target, Key key, bool handled = false) { target.RaiseEvent(new KeyEventArgs { RoutedEvent = InputElement.KeyDownEvent, Key = key, - Source = target + Source = target, + Handled = handled }); Dispatcher.UIThread.RunJobs(); } private static IReadOnlyList Items(FwOptionPicker picker) - => (picker.OptionsList.ItemsSource as IEnumerable)?.ToList() - ?? new List(); + => picker.CurrentItems; + + [AvaloniaTest] + public void OptionsRenderInline_InsideThePickerItself_NoSecondFloatingDropdown() + { + // The thick grey border + flaky arrow keys came from AutoCompleteBox opening a SECOND + // popup (PART_SuggestionsContainer) nested inside the host flyout. The picker must show + // its filter box AND its options list inside its OWN visual tree — the host flyout is + // the only popup — so there is no separate grey-chromed dropdown surface to fight. + var (picker, _, _, _) = ShowStatic(); + picker.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(picker.GetVisualDescendants().OfType(), Is.Empty, + "the picker no longer hosts an AutoCompleteBox (and its nested grey dropdown popup)"); + Assert.That(picker.GetVisualDescendants().Contains(picker.FilterBox), Is.True, + "the filter box is part of the picker's own (single-popup) visual tree"); + Assert.That(picker.GetVisualDescendants().Contains(picker.OptionsList), Is.True, + "the options list renders INLINE under the filter box — not in a second floating popup"); + } [AvaloniaTest] public void OpensWithFilterFocused_Watermarked_AndAllOptionsListed() @@ -133,6 +166,46 @@ public void DownAndUp_MoveTheHighlight_AndEnterCommitsIt() Assert.That(committed.Single().Key, Is.EqualTo("u-sky"), "Enter commits the highlighted option"); } + [AvaloniaTest] + public void RealHeadlessKeyPresses_OnTheFocusedFilterBox_MoveAndCommitSelections() + { + var (picker, window, committed, _) = ShowStatic(); + + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(1), + "a real Down keypress on the focused filter box should move the selector highlight"); + + window.KeyTextInput("s"); + Assert.That(Items(picker).Select(o => o.Key), Is.EqualTo(new[] { "u", "u-sky", "p" }), + "typing through the headless input path should update the filtered result set"); + + window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(1), + "Down should still move through the filtered result set on the real key path"); + + window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None); + Assert.That(committed.Select(o => o.Key), Is.EqualTo(new[] { "u-sky" }), + "Enter should commit the highlighted filtered option on the real headless key path"); + } + + [AvaloniaTest] + public void HandledArrowKeys_FromTheFilterBox_StillMoveTheFilteredSelection() + { + var (picker, window, committed, _) = ShowStatic(); + + window.KeyTextInput("s"); + Assert.That(Items(picker).Select(o => o.Key), Is.EqualTo(new[] { "u", "u-sky", "p" }), + "filtering leaves the matching subset in list order"); + + RaiseKey(picker.FilterBox, Key.Down, handled: true); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(1), + "Down should still advance even when the TextBox already handled the key"); + + RaiseKey(picker.FilterBox, Key.Enter, handled: true); + Assert.That(committed.Select(o => o.Key), Is.EqualTo(new[] { "u-sky" }), + "Enter should commit the highlighted filtered option through the same handled event path"); + } + [AvaloniaTest] public void Escape_RaisesDismissed_WithoutCommitting() { @@ -216,7 +289,7 @@ public void SearchBackedPicker_EnumeratesNothingUpFront_AndForwardsTheQuery() window.Show(); Dispatcher.UIThread.RunJobs(); - Assert.That(picker.OptionsList.ItemsSource, Is.Null, + Assert.That(picker.CurrentItems, Is.Empty, "lexicons search, lists enumerate: nothing materializes before the user types"); window.KeyTextInput("ca"); @@ -228,6 +301,32 @@ public void SearchBackedPicker_EnumeratesNothingUpFront_AndForwardsTheQuery() "Enter commits the first search result by default"); } + [AvaloniaTest] + public void UnavailableOptions_AreGreyedOut_AndSkippedByDefaultSelectionAndCommit() + { + var (picker, window, committed) = ShowStaticWithUnavailable("u", "u-sky"); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(2), + "default selection skips options that are already selected elsewhere"); + + var texts = picker.OptionsList.GetVisualDescendants().OfType() + .Where(t => Tree().Any(o => o.Name == t.Text)) + .ToDictionary(t => t.Text, t => t); + Assert.That(texts["Universe"].Opacity, Is.LessThan(1.0), "already-selected options are visually muted"); + Assert.That(texts["Sky"].Opacity, Is.LessThan(1.0)); + Assert.That(texts["Weather"].Opacity, Is.EqualTo(1.0).Within(0.01)); + + RaiseKey(picker.FilterBox, Key.Up); + Assert.That(picker.OptionsList.SelectedIndex, Is.EqualTo(2), + "keyboard navigation does not land on unavailable choices"); + + RaiseKey(picker.FilterBox, Key.Enter); + Assert.That(committed.Select(o => o.Key), Is.EqualTo(new[] { "u-weather" }), + "commit ignores unavailable options and uses the first enabled choice"); + } + [AvaloniaTest] public void PointerRelease_OnTheListScrollbar_DoesNotCommit() { diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs index cdb7219a59..97804b0713 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/PocLexEntrySliceTests.cs @@ -84,6 +84,8 @@ public void PocSlice_MorphTypeChooser_UpdatesEntryAndReturnsFocus() slice.MorphTypeChooser.Button.Focus(); slice.MorphTypeChooser.Open(); Dispatcher.UIThread.RunJobs(); + Assert.That(slice.MorphTypeChooser.Picker, Is.Not.Null, + "the POC morph-type popup uses the shared select-from-list control"); var suffix = entry.MorphTypeOptions.Single(o => o.Key == "suffix"); slice.MorphTypeChooser.Select(suffix); @@ -121,6 +123,7 @@ public void PocSlice_UserFacingControls_ExposeStableAutomationMetadata() Assert.That(AutomationProperties.GetAutomationId(slice.LexemeFormEditor.Boxes[0]), Is.EqualTo("LexemeFormEditor.seh")); Assert.That(AutomationProperties.GetAutomationId(slice.SenseGlossEditor), Is.EqualTo("SenseGlossEditor")); Assert.That(AutomationProperties.GetAutomationId(slice.MorphTypeChooser.Button), Is.EqualTo("MorphTypeChooser.Button")); + Assert.That(AutomationProperties.GetAutomationId(slice.MorphTypeChooser.Picker.FilterBox), Is.EqualTo("MorphTypeChooser.Search")); } [AvaloniaTest] @@ -134,7 +137,7 @@ public void PocSlice_SemanticSnapshot_IsDeterministic() Assert.That(second, Is.EqualTo(first), "POC semantic snapshot must be deterministic for parity comparison"); // Sanity: the snapshot captures the three fields and their editor kinds. Assert.That(first, Does.Contain("Lexeme Form | editor=multiws-text")); - Assert.That(first, Does.Contain("Morph Type | editor=popup-chooser")); + Assert.That(first, Does.Contain("Morph Type | editor=shared-option-picker")); Assert.That(first, Does.Contain("Gloss | editor=multiws-text")); } @@ -147,7 +150,7 @@ private static string BuildPocSnapshot(PocLexEntrySlice slice) { var sb = new StringBuilder(); sb.AppendLine($"#0 | Lexeme Form | editor=multiws-text | ws={WsList(slice.LexemeFormEditor)}"); - sb.AppendLine($"#1 | Morph Type | editor=popup-chooser | value={slice.Entry.MorphTypeKey}"); + sb.AppendLine($"#1 | Morph Type | editor=shared-option-picker | value={slice.Entry.MorphTypeKey}"); sb.AppendLine($"#2 | Gloss | editor=multiws-text | ws={WsList(slice.SenseGlossEditor)}"); return sb.ToString(); } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/PocWinFormsHostControlTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/PocWinFormsHostControlTests.cs new file mode 100644 index 0000000000..7df216d03a --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/PocWinFormsHostControlTests.cs @@ -0,0 +1,40 @@ +// Copyright (c) 2026 SIL International +// This software is licensed under the LGPL, version 2.1 or later +// (http://www.gnu.org/licenses/lgpl-2.1.html) + +using System.Reflection; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Poc; + +namespace FwAvaloniaTests +{ + [TestFixture] + public class PocWinFormsHostControlTests + { + private static bool ShouldBypass(bool hostContainsFocus, int keyCode) + { + var method = typeof(PocWinFormsHostControl).GetMethod( + "ShouldBypassWinFormsDirectionalKeyHandling", + BindingFlags.NonPublic | BindingFlags.Static); + Assert.That(method, Is.Not.Null, "test seam missing"); + return (bool)method.Invoke(null, new object[] { hostContainsFocus, keyCode }); + } + + [Test] + public void DirectionalKeys_AreBypassed_WhenAvaloniaHostContainsFocus() + { + Assert.That(ShouldBypass(true, 0x26), Is.True); // Up + Assert.That(ShouldBypass(true, 0x28), Is.True); // Down + Assert.That(ShouldBypass(true, 0x25), Is.True); // Left + Assert.That(ShouldBypass(true, 0x27), Is.True); // Right + } + + [Test] + public void NonDirectionalKeys_AndUnfocusedHost_AreNotBypassed() + { + Assert.That(ShouldBypass(false, 0x26), Is.False); // Up + Assert.That(ShouldBypass(true, 0x0D), Is.False); // Enter + Assert.That(ShouldBypass(true, 0x09), Is.False); // Tab + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs index 9bd1d3beb3..49e49b492c 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/RegionEditingTests.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Avalonia; using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Headless.NUnit; @@ -131,6 +132,28 @@ public void TextChange_StagesThroughTheEditContext() Assert.That(context.TextEdits[0], Is.EqualTo(("Form", "vern", "perro"))); } + [AvaloniaTest] + public void ChooserFlyout_StripsTheHeavyGreyPresenterChrome_SoThePickerOwnsTheOnlyBorder() + { + // The "thick grey border" was the default Fluent FlyoutPresenter (its grey padding + + // border) wrapping the picker. The option flyout must zero that chrome so the picker's + // own thin border is the single visible boundary. + var (view, _, _) = ShowEditable(); + var chooser = Find(view, "MorphTypeChooser"); + + var flyout = (Flyout)chooser.Flyout; + flyout.ShowAt(chooser); + Dispatcher.UIThread.RunJobs(); + + var picker = (FwOptionPicker)flyout.Content; + var presenter = picker.GetVisualAncestors().OfType().FirstOrDefault(); + Assert.That(presenter, Is.Not.Null, "the picker is hosted inside a flyout presenter"); + Assert.That(presenter.Padding, Is.EqualTo(new Thickness(0)), + "no thick grey padding wraps the picker — the heavy presenter chrome is stripped"); + Assert.That(presenter.BorderThickness, Is.EqualTo(new Thickness(0)), + "no heavy grey presenter border — the picker draws the single clean border"); + } + [AvaloniaTest] public void ChooserChange_StagesOptionKeyThroughTheEditContext() { @@ -139,7 +162,10 @@ public void ChooserChange_StagesOptionKeyThroughTheEditContext() Assert.That(chooser, Is.Not.Null, "the chooser renders as the owned flyout field"); Assert.That(chooser.ValueText, Is.EqualTo("stem"), "shows the current selection"); - var picker = (FwOptionPicker)((Flyout)chooser.Flyout).Content; + var flyout = (Flyout)chooser.Flyout; + flyout.ShowAt(chooser); + Dispatcher.UIThread.RunJobs(); + var picker = (FwOptionPicker)flyout.Content; picker.OptionsList.SelectedIndex = 1; // "suffix" picker.CommitHighlighted(); Dispatcher.UIThread.RunJobs(); @@ -240,7 +266,10 @@ public void Chooser_DuplicateDisplayNames_StagesTheOptionAtTheSelectedIndex() window.Show(); Dispatcher.UIThread.RunJobs(); - var picker = (FwOptionPicker)((Flyout)chooser.Flyout).Content; + var flyout = (Flyout)chooser.Flyout; + flyout.ShowAt(chooser); + Dispatcher.UIThread.RunJobs(); + var picker = (FwOptionPicker)flyout.Content; picker.OptionsList.SelectedIndex = 1; picker.CommitHighlighted(); Dispatcher.UIThread.RunJobs(); @@ -359,18 +388,21 @@ public void SearchBackedReferenceVector_SearchFlyout_RendersAndStagesSelectedRes var addButton = vector.GetVisualDescendants().OfType