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..5664d700a7 --- /dev/null +++ b/Docs/architecture/avalonia-fwlayout-parity.md @@ -0,0 +1,186 @@ +# 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. + +## Design + +1. Keep the `XCoreMenuBridge` interceptor contract, its disabled-item + normalization, and the `RecordEditView.ResolveShownRecord` refresh. + `OnDetailMenuRequested` renders the native menu only, with no WinForms + adapter-menu fallback: when native menu construction throws, the error is + logged and no menu is shown; when the menu ids resolve to no items, no menu + is shown and nothing is logged. In both cases the post-menu refresh still + runs (see Divergences). JSON-backed persistence and its Show-all code give + way to 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. | +| Sense or record item header | WinForms renders the HeavySummary SummarySlice as the item's header. Avalonia synthesizes the item header from LexSenseOutline + ShortName and folds a HeavySummary that directly follows it into that header (no repeated row, children at the item's depth). | + +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 + +- **Behavior**: a detail-row menu request whose Avalonia-native menu + construction throws (`XCoreMenuBridge` conversion or the host interceptor). + **WinForms**: the legacy `DataTree` shows the xCore menu through the + WinForms adapter `ContextMenuStrip` (`XWindow.ShowContextMenu` -> + `MenuAdapter`). + **Avalonia**: `RecordEditView.OnDetailMenuRequested` logs the + error and shows no menu; the pending Show-all reveal still ends and the + detail view still recomposes. **Why accepted**: the adapter menu bypasses + the `XCoreMenuBridge` interceptor, so its commands would skip the + exact-slice re-targeting and the post-command Avalonia recompose and leave + the view stale. Such a failure is a defect to fix, made visible through the + log rather than masked by a second rendering. + +- **Behavior**: a detail-row menu request whose ids resolve to no menu items. + **WinForms**: `MenuAdapter.ShowContextMenu` opens the `ContextMenuStrip` + even when it holds nothing, so an empty popup appears at the pointer. + **Avalonia**: no flyout opens and nothing is logged; the post-menu refresh + still runs. **Why accepted**: an empty popup tells the user nothing and + costs a click to dismiss. 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..b09dbe95cd 100644 --- a/Src/Common/Controls/DetailControls/Slice.cs +++ b/Src/Common/Controls/DetailControls/Slice.cs @@ -730,6 +730,23 @@ public virtual bool IsLazyPlaceholder } } + /// + /// The sequence flid represented by a lazy placeholder. Real slices do not expose + /// lazy-sequence metadata. + /// + public virtual int LazySequenceFlid => 0; + + /// + /// The first sequence item index represented by a lazy placeholder. Real slices do not + /// expose lazy-sequence metadata. + /// + 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/AssemblyInfo.cs b/Src/Common/FwAvalonia/AssemblyInfo.cs new file mode 100644 index 0000000000..f12bf947ef --- /dev/null +++ b/Src/Common/FwAvalonia/AssemblyInfo.cs @@ -0,0 +1,8 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FwAvaloniaTests")] +[assembly: InternalsVisibleTo("xWorksTests")] 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..e2f9b94515 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -54,6 +54,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 +83,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 +93,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 +325,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 +587,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 +646,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