Skip to content

Use project .fwlayout files for Avalonia persistence - #1111

Open
johnml1135 wants to merge 15 commits into
mainfrom
avalonia-uses-fwlayout
Open

Use project .fwlayout files for Avalonia persistence#1111
johnml1135 wants to merge 15 commits into
mainfrom
avalonia-uses-fwlayout

Conversation

@johnml1135

@johnml1135 johnml1135 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Hide a field in the Avalonia detail view, move one, or configure its writing
systems, and the change now lands in the project's .fwlayout file — the same
file the legacy Lexicon Edit view reads. Do it in either view and the other
one shows it, and it survives moving the project to another machine.

Until now the Avalonia view kept those settings in a .viewoverride.json store
of its own, so the two views drifted apart and a moved project lost its
Avalonia customizations. That store is deleted here (20 files, 3,313 lines);
nothing in Src references it any more. Existing .viewoverride.json files
are ignored, not migrated — the format never shipped in a release.

The question worth your time is not "does it write the file" — tests cover
that — but whether a layout that WinForms would render now renders the
same
. Composition, fallback, and command targeting were all rebuilt against
the legacy XCore.Inventory, and the contract they must meet is written down
in Docs/architecture/avalonia-fwlayout-parity.md.

Where to look

  • InventoryViewDefinitionSource.cs — the first Avalonia code that writes
    into a live project .fwlayout. Mirrors DataTree.EnsureCustomFields.
  • LayoutResolutionWalk.cs — WinForms fallback order, including the quirk
    that the default search starts at the concrete class's base.
  • RecordEditView.Avalonia.cs — largest rewrite; holds the override
    retirement and the fail-closed command targeting.
  • XmlLayoutImporter.cs — where matching WinForms makes the new view
    render less than the previous Avalonia code did.
  • DetailControls/{DataTree,Slice}.cs — the only WinForms production
    files touched; additive virtuals, no existing path changes.

Deliberately not here

  • Two behaviors were carved onto their own branches: avalonia-viewdef-compile-cache
    (compile-cache rewrite — unmeasured, may be dropped) and
    avalonia-ws-alternatives-with-data (fix for LT-22777).
  • Three more have review branches without code changes:
    avalonia-importer-winforms-strictness, avalonia-layout-choice-fail-closed,
    avalonia-custom-field-placeholder-persistence.
  • Two accepted divergences are recorded in the parity doc; the register is
    otherwise empty.

Verification

xWorks 1,657/1,659 and FwAvalonia 637/638 pass locally (remainder skipped);
comment hygiene, token hygiene and gitlint clean. No manual FLEx run against a
real project — the parity claims rest on tests and on reading the WinForms
sources.


Reading this a year from now — start here

The design contract for this work is durable and lives in the tree, at
Docs/architecture/avalonia-fwlayout-parity.md. It carries the four-field
layout identity, the WinForms behaviors being matched, the acceptance
criteria, and the Divergences register — the list of places Avalonia is
allowed to differ. If you are changing detail-view layout behavior, read it
first: anything not in that register that differs from WinForms is a defect.

What is not in the tree, and lives only here, is why the scope is what it
is: what was tried, what was cut, and what was left in on purpose.

The layer cake
project .fwlayout + shipped XML
        ↓  XCore.Inventory              (shared with WinForms, authoritative)
        ↓  InventoryViewDefinitionSource (clones to an immutable snapshot)
        ↓  ViewDefinitionCompiler        (content-fingerprint cache)
        ↓  DetailComposer                (walks, threads layout identity)
        ↓  Avalonia DataTree             (rows)

Avalonia command → existing xWorks writer → project .fwlayout → recompose

Inventory stays the only thing that loads, merges, and persists layout XML.
FwAvalonia never depends on XCore and never holds a mutable inventory node;
the snapshot is the seam.

Layout identity is the four attributes Inventory itself keys on — class,
type, name, choiceGuid — plus a caller path locating the exact composed
occurrence. A command targets an already-resolved layout; it never re-runs
fallback.

Decisions, and why

Both shipped parts directories are loaded, hand-authored first.
Inventory loads DistFiles/Parts and then
Language Explorer/Configuration/Parts, letting the later files replace
same-id entries. LayoutSourceLoader is first-wins, so the same precedence
is expressed by listing the hand-authored directory first. This matters more
than it looks: CmObject-Detail-HeavySummary, the autoCustom part, and
every generated default layout live only in DistFiles/Parts. Reading
one directory silently empties every sense subtree.

Omit what WinForms omits. An unresolved part ref, or part content
DataTree.ProcessSubpartNode does not recognize, produces a diagnostic and no
row — because that is what the legacy view does. Rendering a visible
"unsupported" placeholder there would itself be a divergence. Constructs
WinForms does render but Avalonia cannot yet support still show as
unsupported rows.

Fail closed on command targets. A persistent command resolves to one
exact hidden WinForms slice by object, field, class, layout, and caller path.
Zero matches or several: the command is disabled and logged. It never falls
back to a nearby occurrence to make itself succeed.

One walk, not two. The fallback algorithm is generic over the live
inventory node and the shipped-file element, so the two resolution paths
cannot drift.

Paths not taken

Migrating .viewoverride.json. Rejected. The format is pre-alpha, hidden
behind a flag, and never shipped in a release. Conversion code would have to
be maintained and eventually deleted anyway; the files are simply inert now.

Keeping both stores in sync. Rejected as the thing that caused the bug.

A committed canonical-JSON snapshot as the load path. Removed. A snapshot
baked at build time cannot reflect a project file a user just edited, which is
the entire point of this work.

Bounding the compile cache here. Moved to avalonia-viewdef-compile-cache.
The capacity bound, Lazy-based deduplication, memoized parts hash, and
instrumentation counters were precautionary: nothing measured said the previous
cache was a problem, and the counters were test-only instrumentation shipping
in production code. The per-compose memo did stay — without it a single
compose rebuilt a snapshot per sense.

Keeping unselected writing systems that hold data. Moved to
avalonia-ws-alternatives-with-data. It is a display rule in its own right,
not layout parity, and LT-22777 already reports it as a user-facing bug.

What this does NOT authorize
  • It does not claim Avalonia implements every WinForms editor or layout
    construct. Gaps stay visible as unsupported rows with named TODOs; they are
    defects to fix, not approved divergences.
  • It does not make .fwlayout the permanent format. The parity doc's
    Retirement section describes replacing it once no supported WinForms path
    depends on it — that migration is unwritten and this branch is not its
    precedent.
  • It does not settle whether the three review branches
    (avalonia-importer-winforms-strictness,
    avalonia-layout-choice-fail-closed,
    avalonia-custom-field-placeholder-persistence) describe behavior that
    should stay. They exist so each can be judged alone; all three are WinForms
    behavior being matched, and DataTree.EnsureCustomFields has written the
    custom-field placeholder ref to project files for years.
Evidence

Fallback orderLayoutResolutionWalk.Resolve was compared line by line
against DataTree.GetTemplateForObjLayout: requested name up the class chain;
at CmObject the name resets to default and the class resets to the concrete
class, but the walk advances to that class's base before the next lookup, so
the concrete class's own default is never checked. Covered by
InventoryViewDefinitionSourceTests.

The override store is gonegit grep for ViewOverride,
viewoverride, and ViewDefinitionOverride across Src returns nothing.

Custom-field placeholder — the ref is set on the live XmlNode the
Inventory handed out, then persisted through PersistOverrideElement,
matching EnsureCustomFields. Generated ref="Custom" siblings are not
persisted; WinForms regenerates them per load.

Notebook record types — a missing RnGenericRec choice layout is cloned
from the no-choice layout, tagged, added to the inventory, and written to the
project file, asserted against a real .fwlayout write.

Show-all — the transient reveal ends when another slice becomes current
and cannot survive a record or Type change; both paths have tests, including
the case where no menu can be shown at all.


This change is Reviewable

@johnml1135 johnml1135 changed the title Plan Avalonia .fwlayout convergence Use fwlayout for Avalonia layout persistence Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown

NUnit Tests

    1 files  ±0      1 suites  ±0   8m 1s ⏱️ - 4m 32s
6 031 tests +8  5 912 ✅  - 30  81 💤 ±0  38 ❌ +38 
6 040 runs  +8  5 921 ✅  - 30  81 💤 ±0  38 ❌ +38 

For more details on these failures, see this check.

Results for commit 7e3eab1. ± Comparison against base commit 2518431.

This pull request removes 110 and adds 118 tests. Note that renamed tests count towards both.
FwAvaloniaTests.DetailOverrideRenderingTests ‑ Applier_AppliesVisibilityAndReorder_ToTheCompiledIR
FwAvaloniaTests.DetailOverrideRenderingTests ‑ DetailView_RendersOnlyTheRowsInTheModel
FwAvaloniaTests.DetailOverrideRenderingTests ‑ DetailView_RendersRowsInModelOrder_SoAReorderIsVisible
FwAvaloniaTests.LayoutChoiceResolutionTests ‑ SelectLayoutForChoice_NoChoicelessFallbackAndNoMatch_ReturnsFirst
FwAvaloniaTests.LayoutChoiceResolutionTests ‑ SelectLayoutForChoice_UnknownOrBlankGuid_FallsBackToChoicelessVariant
FwAvaloniaTests.LayoutImportCoverageTests ‑ CmAnthroItemNestedLayout_SummaryStaysUnresolved_MatchingLegacyOmission
FwAvaloniaTests.ViewDefinitionLoaderTests ‑ Gated_InvalidJson_FallsBackToXml_WithDiagnostic_NotThrow
FwAvaloniaTests.ViewDefinitionLoaderTests ‑ Gated_MissingJson_FallsBackToXml_WithDiagnostic
FwAvaloniaTests.ViewDefinitionLoaderTests ‑ Gated_WithValidJson_LoadsFromJson_NotXml
FwAvaloniaTests.ViewDefinitionLoaderTests ‑ NotGated_LoadsFromXml
…
FwAvaloniaTests.DataTreeTests ‑ DetailView_ReportsTheFieldWhoseEditorReceivesFocus
FwAvaloniaTests.DataTreeTests ‑ DetailView_ReportsTheFieldWhoseLabelIsClicked
FwAvaloniaTests.DataTreeTests ‑ PointerFocus_ReportsFieldOnlyAfterTheActivationIsReleased
FwAvaloniaTests.DetailMenuRequestTests ‑ DetailMenuFlyout_InvokesClosedActionAfterTheMenuCloses
FwAvaloniaTests.DetailMenuRequestTests ‑ MenuAffordances_ReportTheirExactOwnerOnFocusAndBeforeActivation
FwAvaloniaTests.DetailRenderingTests ‑ DetailView_RendersOnlyTheRowsInTheModel
FwAvaloniaTests.DetailRenderingTests ‑ DetailView_RendersRowsInModelOrder_SoAReorderIsVisible
FwAvaloniaTests.IdentityStabilityTests ‑ DetailField_LayoutPathSetterFreezesSourceList
FwAvaloniaTests.IdentityStabilityTests ‑ DetailLayoutIdentity_UsesCanonicalNullAndEmptyChoiceSemantics
FwAvaloniaTests.IdentityStabilityTests ‑ DetailLayoutPartIdentity_FreezesCallerLayoutPathForSetMembership
…
This pull request removes 2 skipped tests and adds 2 skipped tests. Note that renamed tests count towards both.
SIL.FieldWorks.XWorks.DetailCommandAdapterHardeningTests ‑ EnsureMenuCommandAdapter_NoSliceMatchesHvo_ClearsCurrentSliceRatherThanMisTarget
SIL.FieldWorks.XWorks.DetailCommandAdapterHardeningTests ‑ EnsureMenuCommandAdapter_TargetInLazySliceRange_RealizesAndTargetsTheRightObject
SIL.FieldWorks.XWorks.DetailCommandAdapterHardeningTests ‑ EnsureMenuCommandTarget_NoSliceMatchesHvo_ClearsCurrentSliceRatherThanMisTarget
SIL.FieldWorks.XWorks.DetailCommandAdapterHardeningTests ‑ EnsureMenuCommandTarget_TargetInLazySliceRange_RealizesAndTargetsTheRightObject

♻️ This comment has been updated with latest results.

@codecov-commenter

codecov-commenter commented Aug 27, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 75.34517% with 375 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.54%. Comparing base (1ab408b) to head (7e3eab1).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
...xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs 51.68% 139 Missing and 62 partials ⚠️
Src/xWorks/Avalonia/Composer/DetailComposer.cs 84.38% 27 Missing and 35 partials ⚠️
...Avalonia/Composer/InventoryViewDefinitionSource.cs 79.83% 10 Missing and 14 partials ⚠️
Src/Common/Controls/DetailControls/DataTree.cs 0.00% 11 Missing and 1 partial ⚠️
...on/FwAvalonia/ViewDefinition/LayoutSourceLoader.cs 68.42% 5 Missing and 7 partials ⚠️
...n/FwAvalonia/ViewDefinition/ViewDefinitionModel.cs 90.90% 3 Missing and 7 partials ⚠️
...c/xWorks/Avalonia/Composer/LayoutResolutionWalk.cs 82.14% 5 Missing and 5 partials ⚠️
Src/Common/FwAvalonia/Detail/DetailModel.cs 84.74% 1 Missing and 8 partials ⚠️
...mon/FwAvalonia/ViewDefinition/XmlLayoutImporter.cs 92.45% 6 Missing and 2 partials ⚠️
...nia/ViewDefinition/ViewDefinitionJsonSerializer.cs 91.02% 1 Missing and 6 partials ⚠️
... and 7 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1111      +/-   ##
==========================================
- Coverage   38.57%   38.54%   -0.04%     
==========================================
  Files        1514     1508       -6     
  Lines      351014   351220     +206     
  Branches    40355    40367      +12     
==========================================
- Hits       135418   135388      -30     
- Misses     186380   186604     +224     
- Partials    29216    29228      +12     
Files with missing lines Coverage Δ
Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs 95.31% <100.00%> (+0.15%) ⬆️
Src/Common/FwAvalonia/Detail/LexiconFirstSlice.cs 77.08% <100.00%> (+0.73%) ⬆️
.../FwAvalonia/ViewDefinition/LayoutImportCoverage.cs 93.29% <100.00%> (+0.03%) ⬆️
Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs 87.32% <ø> (+8.45%) ⬆️
Src/Common/FwAvalonia/AvaloniaHostControlBase.cs 49.39% <0.00%> (ø)
Src/Common/FwAvalonia/DetailHostControl.cs 57.14% <0.00%> (ø)
Src/Common/Controls/DetailControls/Slice.cs 61.88% <0.00%> (+3.70%) ⬆️
Src/Common/FwAvalonia/Detail/DataTree.cs 96.67% <91.42%> (-0.53%) ⬇️
.../FwAvalonia/ViewDefinition/ViewDefinitionLoader.cs 0.00% <0.00%> (-93.03%) ⬇️
...wAvalonia/ViewDefinition/ViewDefinitionCacheKey.cs 65.21% <73.33%> (-4.02%) ⬇️
... and 11 more

... and 44 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@johnml1135 johnml1135 changed the title Use fwlayout for Avalonia layout persistence Use project .fwlayout files for Avalonia persistence Aug 27, 2026
@johnml1135
johnml1135 marked this pull request as ready for review August 27, 2026 11:01
@mark-sil

Copy link
Copy Markdown
Contributor

What is the reason PR's 1097 and 1108 could not have gone in before this PR? Would it have significantly changed this PR if those were in first? Please explain why the expectation is that those PR's rebase on this PR.

@mark-sil mark-sil left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the reason PR's 1097 and 1108 could not have gone in before this PR? Would it have significantly changed this PR if those were in first? Please explain why the expectation is that those PR's rebase on this PR.

@mark-sil made 1 comment.
Reviewable status: 0 of 36 files reviewed, all discussions resolved.

johnml1135 and others added 12 commits September 4, 2026 23:13
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.
Slice.cs's LazySequenceFlid summary described both the flid and the
index property that follows it; split so each member states only its
own contract, matching LazySequenceIndex's own summary.

InventoryViewDefinitionSource.GetSnapshot(string,...) had a
caller-framed summary; restate it as the method's own contract (class
name resolution, no live object, no CmCustomItem writing-system
mapping).

Move the "resolve the layout set" comment in DetailComposer.cs from
above IsPluralMagicWritingSystem to above ResolveTextRowWritingSystems,
which it actually describes.

Remove the unused Avalonia.Input import from FwAvalonia/Detail/
DataTree.cs; every reference there is already fully qualified.

Fix an over-indented diagnostics.Add call in XmlLayoutImporter.cs's
choice-clause branch.

Switch the useName != "default" checks in
InventoryViewDefinitionSource.GetSnapshot and DetailComposer's
CompileForClass from OrdinalIgnoreCase to Ordinal, matching the
WinForms reference comparison.

Add the sense/record item header row to the fwlayout parity doc's
behavior-mapping table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InventoryViewDefinitionSource.GetSnapshot expanded custom fields on an
XElement copy of the resolved layout and, when the placeholder lacked
ref="_CustomFieldPlaceholder", persisted that copy. The XmlNode the
Inventory had handed out never gained the ref, so any other holder of
that node stayed stale and the persisted node was a re-parsed copy
rather than the node WinForms EnsureCustomFields would have written.

ExpandCustomFields now hands the callback the placeholder that just
gained its ref. The source maps it to the live part[@customFields] at
the same document-order index, sets the ref on that XmlNode, and
persists its nearest layout or part ancestor through
Inventory.PersistOverrideElement, which imports the node and replaces
the cached entry under the same key. The snapshot copy stays the only
thing that grows generated part ref="Custom" siblings, so the project
.fwlayout records the placeholder ref alone.

Tests cover the live node carrying the ref, the second snapshot leaving
the persisted file untouched, and the persisted layout containing no
generated Custom parts. Custom-field metadata survives the fixture's
per-test undo, so the fixture now clears the static FieldDescription
list in teardown and names each test's field uniquely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parity rule keeps constructs WinForms renders but Avalonia cannot yet
support visible as Unsupported rows, and omits content WinForms itself
omits, reporting it only through import diagnostics. The branch already
applied that to unresolved part refs and to unrecognized part content;
this finishes the remaining XmlLayoutImporter sites, each checked against
DataTree:

- <generate> and unknown container elements: ProcessPartRefNode expands
  only sublayout/indent/part (PartGenerator serves browse columns), so
  WinForms renders nothing for them.
- <part> without ref, at layout level or injected under obj/seq:
  GetMandatoryAttributeValue(partRef, "ref") throws, nothing renders.
- Injected child whose ref cannot be resolved: omitted like any
  unresolved part ("Just omit the missing part").
- Non-structural slice content children (deParams, chooserInfo facets,
  unknown elements): ProcessSubpartNode ignores them.
- Caller children under a slice part other than <indent>/<part>:
  Slice.CreateIndentedNodes consults only the caller's <indent>.
- Non-<part> caller children under an obj/seq part: CreateSlicesFor
  unifies them into each item's layout, which the composer reproduces
  from SourceCallerXml, so a row here would duplicate that content.

Every diagnostic stays. <if>/<ifnot>/<choice> with an unparseable
condition keep their Unsupported row because WinForms renders content
there. Tests assert the omission per site; the two that named the old
behavior are renamed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-up to 198029c.

A bare <part> directly under a slice part's caller was still imported as
a child node. Slice.GenerateChildren and CreateIndentedNodes read only
caller.SelectSingleNode("indent"), so WinForms never renders such a
part; it now falls into the caller-children-dropped branch (reported,
omitted). <indent> handling is unchanged.

Omitted elements no longer append to the output list, so siblings that
shared $"{parentPath}/#{output.Count}" collided on the diagnostic
NodePath (a <generate> next to an unknown element, a ref-less injected
part next to an unresolvable one). Emitted nodes keep that numbering;
every omitted-element diagnostic now goes through OmittedPath, which
uses the layout-relative LegacyLayoutCallerPath and falls back to
name[ordinal] for part-inventory slice content that lives outside a
layout. Tests assert the two paths differ.

The two comments the hygiene pass split mid-sentence are single lines
again, and the SliceWithUnknownChild fixture drops its editor so the
Field-not-Group assertion exercises the promotion boundary it names.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
EnsureMenuCommandAdapter had no production callers: OnDetailMenuRequested
materializes items through CreateNativeDetailMenuItems, and each item's
Execute re-targets the hidden adapter tree at click time through
EnsureMenuCommandTarget or EnsurePersistentMenuCommandTarget. Delete the
wrapper and point the three test fixtures that reached it by reflection
at EnsureMenuCommandTarget, which has the same signature.

The WinForms adapter-menu fallback origin/main used when native
materialization failed is superseded, not lost. It rendered the same
xCore ChoiceGroup, so an empty native menu meant an empty WinForms menu,
and a WinForms menu would bypass the interceptor and the post-menu
refresh that keeps the Avalonia view current. The parity doc now says
the native menu is the sole rendering, the stale XCoreMenuBridge note
about callers falling back is corrected, and a new test drives
OnDetailMenuRequested with a request that resolves to no items to prove
the pending Show-all reveal still ends and the detail view refreshes.

Fix the DetailObjectCommandExecutionTests class comment so it describes
the click-time targeting route instead of a step that no longer exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parity doc's Divergences register was empty even though the menu
paths differ once native construction throws. On origin/main an
exception in XCoreMenuBridge conversion fell through to the WinForms
adapter ContextMenuStrip (XWindow.ShowContextMenu -> MenuAdapter), so a
usable menu still appeared. The Avalonia host now logs the error and
shows no menu, because that adapter menu bypasses the bridge
interceptor: its commands skip the exact-slice re-targeting and the
post-command recompose and leave the view stale. Record that entry, and
sharpen plan item 1 so it says logging happens only on the exception
path while the zero-items case stays silent.

Add a test that drives OnDetailMenuRequested with an in-string request
whose context menu id XWindow cannot resolve, so the bridge throws. It
asserts the failure reaches the log, the pending Show-all reveal ends,
and the hosted detail model recomposes, matching the zero-items test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InventoryViewDefinitionSource.GetSnapshot and DetailComposer.CompileForClass
each carried a copy of the WinForms GetTemplateForObjLayout fallback walk:
try the requested name up the class chain, restart at the concrete class's
base with "default" once CmObject is reached, throw when that search also
reaches CmObject, guard against a metadata cycle, and record the base-class
map for part resolution. Two copies could drift, and the parity plan (item 4)
wants one algorithm.

LayoutResolutionWalk.Resolve now owns the walk. Each caller supplies only its
lookup: the inventory source looks up the live Inventory node and folds the
RnGenericRec clone-and-persist step into the delegate so it still runs per
class visited; the shipped-file compiler looks up its layout index. Both
maps are now case-insensitive, which is safe because
ViewDefinitionSourceSnapshot already copies them into a case-insensitive
dictionary. CompileForClass's "No exact layout found" wording becomes the
shared "No matching layout found"; nothing asserted on the old text.

Review follow-ups from the placeholder-persistence work: the snapshot copy
and the live inventory node now select placeholders through one shared
DetailComposer.IsCustomFieldPlaceholder predicate instead of two filters
that could drift, and the persisted-file test asserts on parsed part
attributes rather than raw substrings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dropping the process-wide (class, layout) memo made every CompileForObject
call during ONE compose build a fresh ViewDefinitionSourceSnapshot and
SHA-256 the ~300 KB parts XML before the compiler cache could hit, once per
sense, example, allomorph, menu-binding peek, sublayout and embedded view.
Composing the test entry fingerprinted 9 snapshots with two senses and 16
after adding three more senses with examples and an allomorph.

ComposeState now memoizes compiled models per compose, keyed like the
existing item-menu-binding memo on (ClassID, layout, choiceGuid, callerXml),
so later items of an already-compiled class reuse the model. The memo dies
with the compose, so an edited .fwlayout still recompiles on the next one.

The snapshot fingerprint also hashes the parts string once per instance via
a ConditionalWeakTable and folds that digest into the layout hash, so each
remaining fingerprint costs one small hash plus a lookup. Identical content
still yields identical keys and any content change still changes the key.

Internal compile/fingerprint/parts-hash counters, exposed to xWorksTests and
FwAvaloniaTests through a new InternalsVisibleTo, let the tests assert the
bound instead of timing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-compose memo of compiled models (and the item-menu-binding memo it
mirrors) keyed on (ClassID, layout, choiceGuid, callerXml), assuming the
source reads nothing from an object beyond its class. It does read one more
thing: InventoryViewDefinitionSource.GetSnapshot maps a CmCustomItem's layout
name from its OWNING LIST's WsSelector, so two custom items of one class from
lists with different selectors composed in one pass (a possibility-reference
descent, for example) shared one memo entry and the second item rendered the
first item's layout.

Both keys now carry a resolver discriminator computed by one helper: the
owning list's Hvo for an ICmCustomItem (0 when unowned) and 0 for every other
class. A new ProjectLayoutCompositionTests case composes a custom item whose
Restrictions reference an item from an analysis-selector list and one from a
vernacular-selector list and checks each gets its own CmPossibilityA /
CmPossibilityV marker layout; it failed before this change with the second
item showing the first item's marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Divergences entry described how the Avalonia host used to behave before
this work, which says nothing about the WinForms contract it is measured
against; the entry now states WinForms behavior alone and leaves the
rationale to "Why accepted".

LayoutResolutionWalk's summary named the two callers instead of stating the
walk's own contract, and its exception list omitted the argument checks.

Three menu tests each repeated the same eight-line Show-all arrangement and
two repeated the OnDetailMenuRequested reflection lookup; both now come from
one helper apiece. The log message the failure test asserts on is now a
constant shared with the production call site, and the no-menu test asserts
the other half of the documented contract: a request that simply resolves to
no items logs nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The parity document described its steps as work to do to a pull request,
though it outlives that request and describes how the detail view is built.
State the design instead.

Nothing said why LayoutNotFoundException is rethrown past the handler that
falls back to the host view two lines below, so the clause reads as
redundant. A layout the class hierarchy cannot satisfy means a corrupt
project, which WinForms also reports rather than hides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@johnml1135
johnml1135 force-pushed the avalonia-uses-fwlayout branch from 8a3637a to 2a419c4 Compare September 5, 2026 17:40
johnml1135 and others added 3 commits September 5, 2026 13:59
Both operands of the refreshAfterMenu expression consume pending state, so
|| would leave the Show-all reveal on the previous slice whenever a focus
refresh was already pending. Nothing said so, inviting a future edit to
"fix" the operator.

The compile counters described themselves by the tests that read them
rather than by what they count, and RecordEditView.Avalonia.cs had lost its
byte-order mark.
Two changes rode along with fwlayout persistence without being needed by
it, so they are moving to branches of their own.

The compiler swapped its cache for a capacity-bounded one with Lazy-based
deduplication, memoized the parts-source hash, and counted compiles and
fingerprints so a test could bound them. Nothing measured said the old
cache was a problem. The simple locked dictionary comes back, and the
counters stop shipping in production code. The per-compose memo in the
composer stays, since it is what stops one compose recompiling a layout
per item.

Restricting a row to its configured writing systems also kept any
unselected alternative that happened to hold data. That is a display rule
in its own right, not something layout parity asks for, so the row now
shows exactly the configured set.

Both live on avalonia-viewdef-compile-cache and
avalonia-ws-alternatives-with-data.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The design section said both menu outcomes were registered as divergences,
but only the throwing one was. WinForms opens its adapter menu even when it
holds no items, so an empty popup appears where Avalonia now opens nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants