From 8366d83abe219261db217a7f17aac2e443655e7a Mon Sep 17 00:00:00 2001 From: John Lambert Date: Mon, 31 Aug 2026 13:44:30 -0400 Subject: [PATCH 01/15] feat: compose Avalonia detail views from shared fwlayout layouts The Avalonia detail view built its own JSON override stack for layout customisation, so changes made there never reached the .fwlayout files the legacy Lexicon Edit view reads. The two views drifted apart, and a project moved between machines lost its Avalonia customisations. Source detail composition from the legacy Inventory instead. A new InventoryViewDefinitionSource turns Inventory layout nodes into view definitions, DetailComposer consumes them, and the Avalonia host reads and writes the project's shared .fwlayout files. Layout commands from the legacy menus now go through the same writers, so a change made in either view shows up in the other and survives a reload. Mirror WinForms layout resolution exactly: the four-field layout identity (class, type, name, choiceGuid) rides every compiled model, composed field, and command target; the named-then-default fallback walks the class chain the way GetTemplateForObjLayout does, including its skip of the concrete class's default; a new Notebook record type clones and persists its choice layout; and Show all right now is a transient reveal that ends when another slice becomes current. Load the same shipped inventories the legacy Inventory loads, DistFiles/Parts and then Language Explorer/Configuration/Parts. The composer had read only the second, so CmObject-Detail-HeavySummary, the generated default layouts, and the autoCustom part did not exist for it and every sense subtree vanished once unresolved parts stopped being recovered. Omit what DataTree omits (unresolved part refs, unrecognised part content) instead of rendering placeholder rows, and shape autoCustom rows from the field's WsSelector like MakeAutoCustomSlice. Retire the now-dead override stack: the applier, differ, editor, JSON serialiser, store, both migrators, and their tests. Record the plan, the WinForms behaviors being mirrored, and the empty divergence register in Docs/architecture/avalonia-fwlayout-parity.md. Cover the new path with layout persistence parity tests, project layout composition tests, identity stability tests, and detail object command execution tests. --- CONTEXT.md | 3 +- Docs/architecture/avalonia-fwlayout-parity.md | 162 ++++ .../Controls/DetailControls/DataTree.cs | 27 + Src/Common/Controls/DetailControls/Slice.cs | 14 + .../FwAvalonia/AvaloniaHostControlBase.cs | 8 +- Src/Common/FwAvalonia/Detail/DataTree.cs | 46 +- .../FwAvalonia/Detail/DetailMenuFlyout.cs | 5 +- Src/Common/FwAvalonia/Detail/DetailModel.cs | 184 +++- .../FwAvalonia/Detail/LexiconFirstSlice.cs | 5 +- Src/Common/FwAvalonia/DetailHostControl.cs | 5 +- .../FwAvaloniaTests/CanonicalJsonTests.cs | 82 +- .../FwAvaloniaTests/DetailMenuTests.cs | 62 ++ .../FwAvaloniaTests/DetailModelTests.cs | 79 ++ .../DetailOverrideRenderingTests.cs | 117 --- .../FwAvaloniaTests/DetailRenderingTests.cs | 68 ++ .../FwAvaloniaTests/IdentityStabilityTests.cs | 123 +++ .../LayoutChoiceResolutionTests.cs | 47 +- .../LayoutImportCoverageTests.cs | 118 +-- .../ViewDefinitionLoaderTests.cs | 81 -- .../ViewDefinitionOverrideApplierTests.cs | 276 ------ .../ViewDefinitionOverrideDifferTests.cs | 153 --- .../ViewDefinitionOverrideEdgeCaseTests.cs | 197 ---- .../ViewDefinitionOverrideEditorTests.cs | 167 ---- ...ViewDefinitionOverrideFileMigratorTests.cs | 104 -- ...ewDefinitionOverrideJsonSerializerTests.cs | 206 ---- .../ViewDefinitionOverrideMigratorTests.cs | 81 -- .../ViewDefinitionOverrideStoreTests.cs | 139 --- .../FwAvaloniaTests/ViewDefinitionTests.cs | 313 +++++- .../ViewDefinition/IViewDefinitionImporter.cs | 9 +- .../ViewDefinition/LayoutImportCoverage.cs | 4 +- .../ViewDefinition/LayoutSourceLoader.cs | 117 ++- .../ViewDefinition/ViewDefinitionCacheKey.cs | 42 +- .../ViewDefinition/ViewDefinitionCompiler.cs | 252 ++++- .../ViewDefinitionJsonSerializer.cs | 100 +- .../ViewDefinition/ViewDefinitionLoader.cs | 72 +- .../ViewDefinition/ViewDefinitionModel.cs | 255 ++++- .../ViewDefinitionOverrideApplier.cs | 325 ------- .../ViewDefinitionOverrideDiffer.cs | 383 -------- .../ViewDefinitionOverrideEditor.cs | 208 ---- .../ViewDefinitionOverrideFileMigrator.cs | 71 -- .../ViewDefinitionOverrideJsonSerializer.cs | 203 ---- .../ViewDefinitionOverrideMigrator.cs | 63 -- .../ViewDefinitionOverrideStore.cs | 143 --- .../ViewDefinition/XmlLayoutImporter.cs | 227 +++-- .../Avalonia/Composer/DetailComposer.cs | 898 ++++++++++------- .../Composer/InventoryViewDefinitionSource.cs | 207 ++++ .../Avalonia/DetailOverrideMigration.cs | 68 -- .../Hosting/RecordEditView.Avalonia.cs | 918 +++++++++++------- .../Composer/DetailComposerOverrideTests.cs | 237 ----- .../Composer/DetailEditContextEditingTests.cs | 44 +- .../Composer/DetailOverrideMigrationTests.cs | 91 -- .../Composer/FieldTypeComposerTests.cs | 136 +-- .../InventoryViewDefinitionSourceTests.cs | 412 ++++++++ .../Composer/ProjectLayoutCompositionTests.cs | 712 ++++++++++++++ .../DetailCommandAdapterHardeningTests.cs | 232 ++++- .../DetailObjectCommandExecutionTests.cs | 599 +++++------- .../Hosting/LayoutPersistenceParityTests.cs | 651 +++++++++++++ .../PersistentIdentityStabilityTests.cs | 41 + .../Hosting/RecordEditViewSwitchTests.cs | 116 ++- 59 files changed, 5840 insertions(+), 4868 deletions(-) create mode 100644 Docs/architecture/avalonia-fwlayout-parity.md delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DetailOverrideRenderingTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/DetailRenderingTests.cs create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/IdentityStabilityTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionLoaderTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideApplierTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideDifferTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEdgeCaseTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideEditorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideFileMigratorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideJsonSerializerTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideMigratorTests.cs delete mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/ViewDefinitionOverrideStoreTests.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideApplier.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideDiffer.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideEditor.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideFileMigrator.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideJsonSerializer.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideMigrator.cs delete mode 100644 Src/Common/FwAvalonia/ViewDefinition/ViewDefinitionOverrideStore.cs create mode 100644 Src/xWorks/Avalonia/Composer/InventoryViewDefinitionSource.cs delete mode 100644 Src/xWorks/Avalonia/DetailOverrideMigration.cs delete mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/DetailComposerOverrideTests.cs delete mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/DetailOverrideMigrationTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/InventoryViewDefinitionSourceTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Composer/ProjectLayoutCompositionTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/LayoutPersistenceParityTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/PersistentIdentityStabilityTests.cs diff --git a/CONTEXT.md b/CONTEXT.md index c55b89971c..1283015547 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -75,7 +75,8 @@ It is intentionally not a full architecture manual. It should stay biased toward - **Seam**: A framework-neutral substitution point that lets the Avalonia layer consume product behavior without referencing LCModel or WinForms — `IEditSession`, `IUiScheduler`, `IDetailLifetime`, `IDetailRefreshCoordinator`, `IXCoreCommandBridge`, `IRecordNavigationContext` in `FwAvalonia/Seams`, alongside boundary data contracts such as `IFwClipboard`. Product implementations live at the xWorks edge. Deliberately narrow: direct Avalonia APIs remain allowed at the UI edge, and `Seams/` holds only real substitution points — not helpers that merely live near one. - **Fenced edit session**: One `IEditSession` wrapping one LCModel undo task, opened lazily on the first staged edit and ended exactly once by Commit or Cancel. Prevents orphaned undo tasks; implemented by `LcmDetailEditSession`. - **Settle**: The single auto-save policy for an open edit session — commit if valid, otherwise roll back and notify. Invoked on navigation, window deactivate, undo, and teardown (`DetailEditContextHolder.Settle`). -- **Parity**: Behavioral/visual equivalence between a legacy WinForms view or dialog and its Avalonia replacement. Gaps are recorded as `// TODO` with the blocking facts named, or as approved divergences -- never left implicit. (The older `// PARITY` marker is retired.) +- **Parity**: Behavioral/visual equivalence between a legacy WinForms view or dialog and its Avalonia replacement. WinForms behavior is the default contract. Any intentional difference requires explicit human approval and an entry in the relevant parity document; an unlisted difference is a defect. Gaps are recorded as `// TODO` with the blocking facts named, never left implicit. (The older `// PARITY` marker is retired.) +- **Approved divergence**: An Avalonia behavior that intentionally differs from WinForms, has explicit human approval, and is listed in the relevant parity document. For `.fwlayout` behavior, that document is `Docs/architecture/avalonia-fwlayout-parity.md`. - **Preview vs POC**: In the Avalonia migration, **preview** means a lightweight sample or design-time path that reuses the shared detail renderer — including the standalone preview host, which runs a view or dialog without a language project; **POC** refers only to the retired spike/evidence vocabulary and should not name live runtime code paths. - **StringTable localization**: The singleton `StringTable` (FwUtils) loads `strings-.xml` from the Language Explorer configuration folder and localizes text that originates in XML configuration files — layout/part labels, browse column headings, and XCore menu/command labels. `StringTable.Table.LocalizeAttributeValue(...)` (and `XmlUtils.GetLocalizedAttributeValue(...)`) look the attribute value up in the file's `LocalizedAttributes` group and return the English value unchanged when no localization exists. - **`.resx` localization strategy**: FieldWorks-owned forms and controls keep their strings in project `.resx` resources, resolved through `ResourceManager` and shipped as satellite assemblies. New FieldWorks-owned UI — including the Avalonia views and dialogs (`FwAvaloniaStrings.resx`, `FwAvaloniaDialogsStrings.resx`) — belongs in this strategy. diff --git a/Docs/architecture/avalonia-fwlayout-parity.md b/Docs/architecture/avalonia-fwlayout-parity.md new file mode 100644 index 0000000000..aa362219ee --- /dev/null +++ b/Docs/architecture/avalonia-fwlayout-parity.md @@ -0,0 +1,162 @@ +# Avalonia `.fwlayout` Parity + +## Intention + +Avalonia detail views use the same project `.fwlayout` files as WinForms and +must reproduce WinForms layout selection, fallback, mutation, persistence, and +transient behavior. WinForms is the default behavioral contract. A difference +is a defect unless it has been explicitly approved and recorded in the +`Divergences` section of this document. + +This work does not convert Avalonia's pre-alpha `.viewoverride.json` files. +Existing JSON overrides are ignored. They are not imported or automatically +deleted. + +This plan covers `.fwlayout` selection, command targeting, mutation, +persistence, and related transient writing-system state. It does not claim that +Avalonia already implements every WinForms editor or layout construct. Such +gaps remain defects to implement and must stay visible as unsupported rows, +with their blocking facts recorded in named TODOs. They are not divergences. +Content that WinForms itself omits (an unresolved part ref, part content +`DataTree.ProcessSubpartNode` does not recognize) is omitted the same way and +reported only through import diagnostics; an unsupported row there would be a +divergence. + +## Persistent layout identity + +An `.fwlayout` layout is identified by all four attributes used by the WinForms +layout inventory: + +| Attribute | Meaning | +| --- | --- | +| `class` | Value of `layout/@class` on the selected XML layout; it can name a base class after fallback. | +| `type` | Value of `layout/@type` on the selected XML layout, normally `detail`. | +| `name` | Value of `layout/@name` on the selected XML layout; it can be `default` after fallback. | +| `choiceGuid` | Value of `layout/@choiceGuid` on the selected XML layout, or absence of that attribute. | + +The values are matched case-insensitively, as they are by the WinForms layout +inventory. An absent `choiceGuid` is distinct from a present-empty `choiceGuid` +and from any other present value. Code that clones or persists a layout preserves +the selected XML key spelling so a case-sensitive physical-file replacement does +not append a duplicate. + +Layout selection has two identities. The requested identity contains the +object's concrete class, requested layout name (or `default`), `detail` type, +and GUID obtained through `layoutChoiceField`. The resolved identity contains +the four attributes of the XML layout that WinForms actually selected after +fallback. Composed fields and commands carry the resolved identity. A command +never repeats layout fallback; it targets that already-resolved layout or fails +closed. + +The caller path is also retained when a field is composed because the same part +can appear through different callers. It is relative to the resolved layout +that WinForms treats as the persistence root after promoting the layout beyond +the final `sublayout`. Each path segment contains the XML element name and its +zero-based ordinal among same-named element siblings. It locates the XML node +to mutate, but it is not a fifth layout-inventory key. + +Runtime occurrence identity also carries the ordered four-field identities of +every selected layout crossed through object or sequence descent. Persistence +remains rooted in the outer layout, while this chain prevents a command or +transient state from matching the same caller path under a different nested +choice or fallback result. A `sublayout` resets both the persistence root and +the runtime layout chain. + +Object HVO and field name are runtime safety checks. They prevent a command +from reaching a stale or ambiguous row, but they are not persistent layout +identity. Avalonia-generated stable IDs are likewise runtime identities and +must not become `.fwlayout` persistence keys. + +## Plan + +1. Rebase PR #1111 onto `origin/main`. Preserve the current + `XCoreMenuBridge` interceptor contract and disabled-item normalization, and + preserve the `RecordEditView.ResolveShownRecord` refresh fix. Replace + conflicting JSON-backed persistence and Show-all code with the parity + behavior defined here. +2. Carry `class`, `type`, `name`, and optional `choiceGuid` through layout + loading, view-definition models, composed fields, command identities, menu + bindings, and nested object or sequence composition. Retain the caller path + for the exact composed occurrence. +3. Load the same shipped inventories as the legacy `Inventory`: + `DistFiles/Parts` (`StandardParts.xml`, `GeneratedParts.xml`, + `Standard.fwlayout`, `Generated.fwlayout`) and then + `Language Explorer/Configuration/Parts`, with the later hand-authored files + replacing same-id parts and same-key layouts. Without the first directory + `CmObject-Detail-HeavySummary`, the generated `default` layouts, and the + `autoCustom` part do not exist and every sense subtree vanishes. +4. Match WinForms layout selection and fallback order exactly. For the + requested name, try the concrete class and then each base class with the + requested choice. For an `RnGenericRec`, a missing choice-specific layout is + first cloned from the no-choice layout of the class currently being tried. + If the named search reaches `CmObject` without a match, WinForms changes the + name to `default` and resets the class variable to the concrete class, but + immediately advances to that class's base before the next lookup; it does + not check the concrete class's default. Thus the default search begins at the + concrete class's base. Throw if that search also reaches `CmObject` without a + match. Apply this algorithm to root layouts, nested `sublayout` elements, + and nested object and sequence layouts. +5. Match WinForms handling of a new Notebook record type. If an + `RnGenericRec` choice layout does not exist, clone the no-choice layout, add + the requested `choiceGuid`, add it to the layout inventory, and persist it + to the project `.fwlayout` file before composing against it. +6. Resolve every persistent layout command to the exact hidden WinForms slice + and use the existing WinForms mutation path. Missing or ambiguous targets + fail closed and are logged instead of changing a nearby layout occurrence. +7. Remove the obsolete Avalonia JSON override path. Do not add conversion, + deletion, or dual-write behavior. +8. Port and extend tests around the shared layout inventory, production command + route, and project `.fwlayout` files. Cover root and nested choice layouts, + repeated callers, new Notebook record types, fallback order, hidden-slice + targeting, field-to-field current-state transitions, record and Type + changes, and missing or ambiguous command targets. + +## Behavior mapping + +| Avalonia action | WinForms behavior to reuse | +| --- | --- | +| Hide or show a field | Update the `visibility` attribute on the caller part through the matching `Slice`. | +| Move a field | Change physical sibling order through `Slice.MoveField`. | +| Configure visible writing systems | Update `visibleWritingSystems` through `MultiStringSlice`, retaining its pronunciation-writing-system side effects. | +| Show all writing systems temporarily | Use WinForms transient state. Do not write the layout file. Reload configured writing systems when the target slice changes from current to not current. | + +For nested edits, WinForms promotes the selected layout after the final +`sublayout` to the persistence root. Avalonia must therefore keep the selected +choice variant and caller path intact so the existing override machinery writes +the intended nested layout rather than a same-named neighbor. + +## Acceptance criteria + +- Given the same project, tool, object, and `.fwlayout` files, Avalonia and + WinForms resolve the same four-field layout identity at the root and at every + nested layout boundary. +- Choice and `sublayout` resolution does not omit or duplicate any field that + Avalonia supports from the selected layout. Constructs WinForms renders but + Avalonia does not yet support remain visibly represented and tracked rather + than being silently dropped; constructs WinForms omits are omitted. +- Every persistent Avalonia layout command changes the same XML node and uses + the same legacy writer that the equivalent WinForms command uses. +- Moving current selection from the target field to another slice reloads the + configured writing systems and ends temporary Show-all. Recomposition after + a Type or record change cannot revive the old transient reveal. +- A new Notebook record type creates and persists the same choice-specific + layout that WinForms creates. +- No command falls back from an exact choice or caller occurrence to a broader + target merely to make the command succeed. +- Old `.viewoverride.json` files have no effect and require no migration. + +## Retirement + +After FieldWorks fully switches to Avalonia and no supported WinForms path +depends on `.fwlayout`, retire `.fwlayout` as the project layout-customization +format. Replace it with an Avalonia-owned JSON format and migrate the project +customizations that must survive the cutover. That future migration must define +the JSON schema, conversion and rollback strategy, validation, and removal of +the legacy layout inventory, hidden WinForms slices, and related command bridge. + +Until that migration is designed, implemented, and validated, `.fwlayout` +remains the sole source of truth. The obsolete pre-alpha `.viewoverride.json` +format described above is not the future format and must not constrain its +design. + +## Divergences diff --git a/Src/Common/Controls/DetailControls/DataTree.cs b/Src/Common/Controls/DetailControls/DataTree.cs index f8923d7cc3..e6d5b9605c 100644 --- a/Src/Common/Controls/DetailControls/DataTree.cs +++ b/Src/Common/Controls/DetailControls/DataTree.cs @@ -5415,6 +5415,33 @@ public override bool IsLazyPlaceholder } } + public override int LazySequenceFlid + { + get + { + CheckDisposed(); + return m_flid; + } + } + + public override int LazySequenceIndex + { + get + { + CheckDisposed(); + return m_ihvoMin; + } + } + + public override object[] LazySequencePath + { + get + { + CheckDisposed(); + return m_path == null ? null : m_path.ToArray(); + } + } + /// /// Turn this dummy slice into whatever it stands for, replacing itself in the data tree's /// slices (where it occupies slot index) with whatever is appropriate. diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs index 6fd9135add..7d95fc4ff4 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -730,6 +730,20 @@ public virtual bool IsLazyPlaceholder } } + /// + /// The sequence flid and item index represented by a lazy placeholder. Real slices do + /// not expose lazy-sequence metadata. + /// + public virtual int LazySequenceFlid => 0; + + /// Gets the first sequence index represented by this lazy placeholder. + public virtual int LazySequenceIndex => -1; + + /// + /// Gets the XML/object path captured when this lazy placeholder was created. + /// + public virtual object[] LazySequencePath => null; + /// /// In some contexts, we use a "ghost" slice to represent data that /// has not yet been created. These are "real" slices, but they don't diff --git a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs index e1a1b176fa..9350965574 100644 --- a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs +++ b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs @@ -131,11 +131,13 @@ private void UpdateCompanionStripHeight() /// The control to anchor the menu to. /// True: Open at the pointer location. /// False: Open under the anchor control. - public void ShowContextMenu(IReadOnlyList items, - Avalonia.Controls.Control anchor, bool atPointer) + /// Optional action invoked after the menu closes. + /// True when a menu was shown. + public bool ShowContextMenu(IReadOnlyList items, + Avalonia.Controls.Control anchor, bool atPointer, Action closed = null) { var target = anchor ?? Host.Content as Avalonia.Controls.Control; - DetailMenuFlyout.Show(items, target, atPointer); + return DetailMenuFlyout.Show(items, target, atPointer, closed) != null; } public void ShowMessage(string message) diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index c62831c056..9344b9f8d2 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -8,6 +8,7 @@ using Avalonia; using Avalonia.Automation; using Avalonia.Controls; +using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; using Avalonia.Styling; @@ -54,6 +55,7 @@ public sealed class DataTree : UserControl private readonly Action _expansionChanged; private readonly Action _menuRequested; private readonly Action _linkRequested; + private readonly Action _fieldFocused; private readonly IFwClipboard _clipboard; // Computed once per view (not per field/row) from the widest WS abbreviation across the @@ -82,7 +84,8 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, Action linkRequested = null, IFwClipboard clipboard = null, Func getLabelColumnWidth = null, - Action labelColumnWidthChanged = null) + Action labelColumnWidthChanged = null, + Action fieldFocused = null) { Model = model ?? throw new ArgumentNullException(nameof(model)); _editContext = editContext; @@ -91,6 +94,7 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, _expansionChanged = expansionChanged; _menuRequested = menuRequested; _linkRequested = linkRequested; + _fieldFocused = fieldFocused; _clipboard = clipboard; _labelColumnWidthChanged = labelColumnWidthChanged; _wsAbbrevColumnWidth = FwMultiWsTextField.ComputeWsAbbrevColumnWidth(Model); @@ -322,13 +326,51 @@ private Control BuildItem(int index, DetailField field) { var fieldContent = AddField(index, field); var content = ApplyRule(fieldContent.Content, index); + content.AddHandler(Avalonia.Input.InputElement.GotFocusEvent, + (s, e) => ReportFocusedField(e, field), + Avalonia.Interactivity.RoutingStrategies.Bubble); + content.AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, + (s, e) => ReportPointerReleasedField(e, field), + Avalonia.Interactivity.RoutingStrategies.Bubble, true); if (fieldContent.Label != null) + { + fieldContent.Label.AddHandler(Avalonia.Input.InputElement.GotFocusEvent, + (s, e) => ReportFocusedField(e, field), + Avalonia.Interactivity.RoutingStrategies.Bubble); + fieldContent.Label.AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, + (s, e) => ReportPointerReleasedField(e, field), + Avalonia.Interactivity.RoutingStrategies.Bubble, true); FormItem.SetLabel(content, fieldContent.Label); + } else FormItem.SetNoLabel(content, true); return content; } + private void ReportFocusedField(Avalonia.Input.GotFocusEventArgs args, + DetailField field) + { + if (args.NavigationMethod == Avalonia.Input.NavigationMethod.Pointer) + return; + _fieldFocused?.Invoke(field); + } + + private void ReportPointerReleasedField(Avalonia.Input.PointerReleasedEventArgs args, + DetailField field) + { + if (!IsMenuActivationControl(args.Source)) + _fieldFocused?.Invoke(field); + } + + private static bool IsMenuActivationControl(object source) + { + if (!(source is Control control)) + return false; + var id = AutomationProperties.GetAutomationId(control); + return id?.EndsWith(".FieldMenu", StringComparison.Ordinal) == true + || id?.EndsWith(".Hotlinks", StringComparison.Ordinal) == true; + } + // 12.1: the legacy 1px inter-slice rule renders as a per-item bottom border; the last // field gets none. private Control ApplyRule(Control content, int index) @@ -546,6 +588,7 @@ private Control WrapWithFieldMenu(Control inner, DetailField field, string autom { // No pointer position is available here, so the menu drops from the // icon rather than from wherever the mouse sits. + _fieldFocused?.Invoke(field); _menuRequested(DetailMenuRequest.FromAnchor(button, field, kind)); }; rail.Child = button; @@ -604,6 +647,7 @@ private Control CreateHotlinkStrip(DetailField field, string automationId, Thick link.Click += (s, e) => { // Drops from the link's bottom-left. + _fieldFocused?.Invoke(field); _menuRequested(DetailMenuRequest.FromAnchor(link, field, DetailMenuKind.Hotlinks)); }; return link; diff --git a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs index b4bc151003..8eddb8f987 100644 --- a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs +++ b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs @@ -71,13 +71,16 @@ public static MenuFlyout Build(IReadOnlyList items) /// The control to open over; null shows nothing. /// False drops the menu from the target's bottom-left corner -- /// the context-menu key, Shift+F10, and keyboard button activation. + /// Optional action invoked after the flyout closes. /// The flyout that was shown, or null when there was nothing to show. public static MenuFlyout Show(IReadOnlyList items, Control target, - bool atPointer) + bool atPointer, Action closed = null) { if (items == null || items.Count == 0 || target == null) return null; var flyout = Build(items); + if (closed != null) + flyout.Closed += (sender, args) => closed(); if (!atPointer) flyout.Placement = PlacementMode.BottomEdgeAlignedLeft; flyout.ShowAt(target, showAtPointer: atPointer); diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 1ba638113d..f4e1feb837 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1478,6 +1478,139 @@ public DetailLinkRequest(DetailField field, DetailChooserLink link) public DetailChooserLink Link { get; } } + /// Identifies one effective legacy layout. + public readonly struct DetailLayoutIdentity : IEquatable + { + private readonly ViewDefinitionIdentity _canonicalIdentity; + + /// Creates an identity from the four legacy layout key attributes. + public DetailLayoutIdentity(string className, string layoutType, string layoutName, + string choiceGuid) + { + _canonicalIdentity = new ViewDefinitionIdentity(className, layoutType, layoutName, + choiceGuid); + } + + /// Gets the layout's class key. + public string ClassName => _canonicalIdentity.ClassName; + /// Gets the layout's type key. + public string LayoutType => _canonicalIdentity.LayoutType; + /// Gets the layout's name key. + public string LayoutName => _canonicalIdentity.LayoutName; + /// Gets the layout's optional choiceGuid key. + public string ChoiceGuid => _canonicalIdentity.ChoiceGuid; + + /// Compares all four layout keys without regard to case. + public bool Equals(DetailLayoutIdentity other) + => _canonicalIdentity.Equals(other._canonicalIdentity); + + /// + public override bool Equals(object obj) + => obj is DetailLayoutIdentity other && Equals(other); + + /// + public override int GetHashCode() => _canonicalIdentity.GetHashCode(); + } + + /// Identifies one caller part inside an effective legacy layout. + public readonly struct DetailLayoutPartIdentity : IEquatable + { + /// Creates an identity for one caller path in a legacy layout. + public DetailLayoutPartIdentity(DetailLayoutIdentity layout, string callerPath, + IReadOnlyList layoutPath = null) + { + Layout = layout; + CallerPath = callerPath; + LayoutPath = Array.AsReadOnly((layoutPath ?? new[] { layout }).ToArray()); + } + + /// Gets the persistence-root layout identity. + public DetailLayoutIdentity Layout { get; } + /// Gets the structural caller path, including crossed object layouts. + public string CallerPath { get; } + /// Gets every selected layout crossed since the persistence root. + public IReadOnlyList LayoutPath { get; } + + /// Compares the layout and case-sensitive structural caller path. + public bool Equals(DetailLayoutPartIdentity other) + => Layout.Equals(other.Layout) + && string.Equals(CallerPath, other.CallerPath, StringComparison.Ordinal) + && LayoutPathEqual(LayoutPath, other.LayoutPath); + + /// + public override bool Equals(object obj) + => obj is DetailLayoutPartIdentity other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = (Layout.GetHashCode() * 397) ^ (CallerPath?.GetHashCode() ?? 0); + foreach (var item in LayoutPath ?? Array.Empty()) + hash = (hash * 397) ^ item.GetHashCode(); + return hash; + } + } + + private static bool LayoutPathEqual(IReadOnlyList left, + IReadOnlyList right) + { + if (ReferenceEquals(left, right)) + return true; + if (left == null || right == null || left.Count != right.Count) + return false; + for (var i = 0; i < left.Count; i++) + { + if (!left[i].Equals(right[i])) + return false; + } + return true; + } + } + + /// Identifies one runtime slice backed by a legacy layout caller. + public readonly struct DetailLayoutSliceIdentity : IEquatable + { + /// Creates an identity for one runtime field backed by a legacy + /// caller. + public DetailLayoutSliceIdentity(DetailLayoutPartIdentity layoutPart, int objectHvo, + string fieldName) + { + LayoutPart = layoutPart; + ObjectHvo = objectHvo; + FieldName = fieldName; + } + + /// Gets the backing legacy layout caller. + public DetailLayoutPartIdentity LayoutPart { get; } + /// Gets the bound object's runtime HVO. + public int ObjectHvo { get; } + /// Gets the bound field name. + public string FieldName { get; } + + /// Compares the caller, runtime object, and case-sensitive field name. + public bool Equals(DetailLayoutSliceIdentity other) + => LayoutPart.Equals(other.LayoutPart) + && ObjectHvo == other.ObjectHvo + && string.Equals(FieldName, other.FieldName, StringComparison.Ordinal); + + /// + public override bool Equals(object obj) + => obj is DetailLayoutSliceIdentity other && Equals(other); + + /// + public override int GetHashCode() + { + unchecked + { + var hash = LayoutPart.GetHashCode(); + hash = (hash * 397) ^ ObjectHvo; + return (hash * 397) ^ (FieldName?.GetHashCode() ?? 0); + } + } + } + /// /// A field on a lexical-edit detail view, projected from a typed and bound to live /// values by an . This is the product contract that replaces the @@ -1604,24 +1737,55 @@ public DetailField( public int ObjectHvo { get; } /// - /// The class of the compiled view definition this row was projected from (advanced-entry-view): - /// the entry's own fields carry "LexEntry"; a row from a descended object (a sense, an - /// allomorph) - /// carries that object's layout class. Paired with it keys the per-project - /// ViewDefinitionOverride store so the per-field menu-button commands (Field - /// Visibility / Move - /// Field) target the right layout. Set by the composer at compose time (null on rows built outside - /// the full-entry composer, e.g. the first-slice fallback). + /// The class key of the layout that the legacy writer will persist. Object and sequence + /// descent retain the outer layout; a sublayout starts a new persistence root. + /// Paired with , it identifies the exact legacy layout command + /// target. + /// Set by the composer at compose time; null on rows built outside the full-entry + /// composer. /// public string ClassName { get; set; } /// - /// The layout name of the compiled view definition this row was projected from (e.g. - /// "Normal"). + /// The name key of the layout that the legacy writer will persist, such as "Normal". /// See . /// public string LayoutName { get; set; } + /// The legacy layout type, normally detail. + public string LayoutType { get; set; } + + /// The selected layout variant, or null for a layout without + /// choiceGuid. + public string ChoiceGuid { get; set; } + + /// The owning caller part's structural address in the effective legacy + /// layout. + public string SourceCallerPath { get; set; } + + private IReadOnlyList _layoutPath; + + /// The ordered selected-layout chain crossed by object or sequence descent. + /// A sublayout starts a new chain. + public IReadOnlyList LayoutPath + { + get => _layoutPath; + set => _layoutPath = value == null ? null : Array.AsReadOnly(value.ToArray()); + } + + /// Gets the four-key identity of the legacy persistence-root layout. + public DetailLayoutIdentity LayoutIdentity + => new DetailLayoutIdentity(ClassName, LayoutType, LayoutName, ChoiceGuid); + + /// Gets the identity of the exact caller within the persistence-root + /// path. + public DetailLayoutPartIdentity LayoutPartIdentity + => new DetailLayoutPartIdentity(LayoutIdentity, SourceCallerPath, LayoutPath); + + /// Gets the runtime identity used for legacy slice matching. + public DetailLayoutSliceIdentity LayoutSliceIdentity + => new DetailLayoutSliceIdentity(LayoutPartIdentity, ObjectHvo, Field); + /// /// The project's available CHARACTER-type style names /// the per-WS editor offers when restyling a selection (sourced by the composer from the project's diff --git a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs index b582547709..2fbf173819 100644 --- a/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs +++ b/Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs @@ -179,7 +179,10 @@ private static ViewNode StampProductLeaf(ViewNode source, string automationId, s => new ViewNode(source.StableId, ViewNodeKind.Field, labelOverride ?? source.Label, source.Abbreviation, source.Field, source.RawEditor, source.EditorClassification, source.WritingSystem, source.Visibility, source.Expansion, source.Indented, source.TargetLayout, null, - source.LocalizationKey, automationId, HostRouting.Product); + source.LocalizationKey, automationId, HostRouting.Product, + sourceCallerPath: source.SourceCallerPath, + optionalWritingSystem: source.OptionalWritingSystem, + forceIncludeEnglish: source.ForceIncludeEnglish); 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, diff --git a/Src/Common/FwAvalonia/DetailHostControl.cs b/Src/Common/FwAvalonia/DetailHostControl.cs index fc7cbdfae5..2fd2d8d890 100644 --- a/Src/Common/FwAvalonia/DetailHostControl.cs +++ b/Src/Common/FwAvalonia/DetailHostControl.cs @@ -37,7 +37,8 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null Action linkRequested = null, IFwClipboard clipboard = null, Func getLabelColumnWidth = null, - Action labelColumnWidthChanged = null) + Action labelColumnWidthChanged = null, + Action fieldFocused = null) { if (detail == null) throw new ArgumentNullException(nameof(detail)); // Splitter position persists per-HOST across re-shows: this long-lived host owns @@ -53,7 +54,7 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null { _rememberedLabelColumnWidth = w; labelColumnWidthChanged?.Invoke(w); - }); + }, fieldFocused); view.EditCompleted += (s, e) => RaiseDetailEditCompleted(); var focusMemento = DetailFocusMemory.Capture(CurrentContent); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs index 1b18330748..dd349074e0 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/CanonicalJsonTests.cs @@ -50,6 +50,57 @@ public void Serialization_IsDeterministic() Is.EqualTo(ViewDefinitionJsonSerializer.Serialize(LexiconFirstSlice.AuthoredFallback()))); } + [Test] + public void ChoiceGuid_RoundTripDistinguishesEmptyFromAbsent() + { + var emptyChoice = new ViewDefinitionModel("RnGenericRec", "Normal", "detail", + new List(), new List(), string.Empty); + var absentChoice = new ViewDefinitionModel("RnGenericRec", "Normal", "detail", + new List(), new List()); + + var emptyReloaded = ViewDefinitionJsonSerializer.Deserialize( + ViewDefinitionJsonSerializer.Serialize(emptyChoice)); + var absentReloaded = ViewDefinitionJsonSerializer.Deserialize( + ViewDefinitionJsonSerializer.Serialize(absentChoice)); + + Assert.That(emptyReloaded.ChoiceGuid, Is.EqualTo(string.Empty)); + Assert.That(absentReloaded.ChoiceGuid, Is.Null); + } + + [Test] + public void RequestedAndResolvedLayoutIdentity_RoundTripsWithoutCollapsingChoice() + { + var selected = new ViewDefinitionModel("LexEntry", "default", "detail", + new List(), new List(), "resolved") + .WithLayoutIdentities("Requested", string.Empty, "default", "resolved"); + + var reloaded = ViewDefinitionJsonSerializer.Deserialize( + ViewDefinitionJsonSerializer.Serialize(selected)); + + Assert.That(reloaded.RequestedLayoutName, Is.EqualTo("Requested")); + Assert.That(reloaded.RequestedChoiceGuid, Is.EqualTo(string.Empty)); + Assert.That(reloaded.ResolvedLayoutName, Is.EqualTo("default")); + Assert.That(reloaded.ResolvedChoiceGuid, Is.EqualTo("resolved")); + } + + [Test] + public void RequestedAndResolvedLayoutIdentity_RoundTripsConcreteAndBaseClasses() + { + var selected = new ViewDefinitionModel("BaseClass", "default", "detail", + new List(), new List(), "resolved") + .WithLayoutIdentities( + new ViewDefinitionIdentity("ConcreteClass", "detail", "Requested", "requested"), + new ViewDefinitionIdentity("BaseClass", "detail", "default", "resolved")); + + var reloaded = ViewDefinitionJsonSerializer.Deserialize( + ViewDefinitionJsonSerializer.Serialize(selected)); + + Assert.That(reloaded.RequestedIdentity, + Is.EqualTo(new ViewDefinitionIdentity("ConcreteClass", "detail", "Requested", "requested"))); + Assert.That(reloaded.ResolvedIdentity, + Is.EqualTo(new ViewDefinitionIdentity("BaseClass", "detail", "default", "resolved"))); + } + [Test] public void UnsupportedFormatVersion_IsRejected() { @@ -93,6 +144,8 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() ghostLabel: "Gloss", forVariant: true, ghostInitMethod: "SetMorphTypeToRoot", + customEditorClass: "SIL.FieldWorks.CustomSlice", + customEditorAssembly: "Custom.dll", condition: new ViewCondition( negated: true, target: "owner", @@ -111,16 +164,27 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() { new ViewChooserLink("goto", "Edit the Publications list", "publicationsEdit"), new ViewChooserLink("simple", "Add a slot", "MakeInflAffixSlotChooserCommand", "TopPOS") - }); + }, + enumStringList: new ViewStringList(new[] { "Hidden", "Visible" }, "EnumLabels"), + visibleWritingSystems: new[] { "en", "fr" }, + toggleValue: true, + sourceCallerPath: "part[2]/if[1]", + layoutChoiceField: "MorphType", + sourceCallerXml: "", + optionalWritingSystem: "all vernacular", + forceIncludeEnglish: true); var model = new ViewDefinitionModel("LexEntry", "Normal", "detail", - new List { node }, new List()); + new List { node }, new List(), "choice-1"); var reloaded = ViewDefinitionJsonSerializer.Deserialize(ViewDefinitionJsonSerializer.Serialize(model)); var r = reloaded.Roots[0]; Assert.Multiple(() => { + Assert.That(reloaded.ChoiceGuid, Is.EqualTo("choice-1"), nameof(reloaded.ChoiceGuid)); Assert.That(r.StableId, Is.EqualTo("n/#0"), nameof(r.StableId)); + Assert.That(r.SourceCallerPath, Is.EqualTo("part[2]/if[1]"), nameof(r.SourceCallerPath)); + Assert.That(r.SourceCallerXml, Is.EqualTo(""), nameof(r.SourceCallerXml)); 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)); @@ -128,10 +192,15 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() 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.OptionalWritingSystem, Is.EqualTo("all vernacular"), nameof(r.OptionalWritingSystem)); + Assert.That(r.ForceIncludeEnglish, Is.True, nameof(r.ForceIncludeEnglish)); 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.LayoutChoiceField, Is.EqualTo("MorphType"), nameof(r.LayoutChoiceField)); + Assert.That(r.VisibleWritingSystems, Is.EqualTo(new[] { "en", "fr" }), + nameof(r.VisibleWritingSystems)); 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)); @@ -147,6 +216,10 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() Assert.That(r.GhostClass, Is.EqualTo("LexSense"), nameof(r.GhostClass)); Assert.That(r.GhostLabel, Is.EqualTo("Gloss"), nameof(r.GhostLabel)); Assert.That(r.ForVariant, Is.True, nameof(r.ForVariant)); + Assert.That(r.CustomEditorClass, Is.EqualTo("SIL.FieldWorks.CustomSlice"), + nameof(r.CustomEditorClass)); + Assert.That(r.CustomEditorAssembly, Is.EqualTo("Custom.dll"), + nameof(r.CustomEditorAssembly)); 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"); @@ -172,6 +245,11 @@ public void EveryViewNodeProperty_SurvivesRoundTrip() 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"); + Assert.That(r.EnumStringList.Ids, Is.EqualTo(new[] { "Hidden", "Visible" }), + "EnumStringList.Ids"); + Assert.That(r.EnumStringList.Group, Is.EqualTo("EnumLabels"), + "EnumStringList.Group"); + Assert.That(r.ToggleValue, Is.True, nameof(r.ToggleValue)); }); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs index 8018d18a04..d3567658ec 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs @@ -200,6 +200,47 @@ public void FieldMenuButton_OnLabelRow_RaisesTheSliceMenuRequest_WithTheLegacyMe "the request carries the bound object so command routing can target it"); } + [AvaloniaTest] + public void MenuAffordances_ReportTheirExactOwnerOnFocusAndBeforeActivation() + { + var gloss = Field("Gloss", DetailFieldKind.Text, menuId: "mnuDataTree-Help"); + var senses = Field("Senses", DetailFieldKind.Header, + hotlinksId: "mnuDataTree-Sense-Hotlinks", collapsible: true); + var events = new List(); + var model = new DetailModel("LexEntry", "Normal", + new List { gloss, senses }, new List()); + var view = new DataTree(model, null, null, null, null, + request => events.Add("menu:" + request.Field.Field), + fieldFocused: focused => events.Add("focus:" + focused.Field)); + var window = new Window { Content = view, Width = 480, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + + var fieldMenu = Find