Skip to content

LT-22673: Convert the Reversal Entries slice to Avalonia - #1163

Draft
papeh wants to merge 7 commits into
mainfrom
feature/LT-22673-Avalonia-convert-reversal-slice
Draft

papeh wants to merge 7 commits into
mainfrom
feature/LT-22673-Avalonia-convert-reversal-slice

Conversation

@papeh

@papeh papeh commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fill out the placeholder ReversalIndexEntryPlugin to work similarly to the WinForms slice. Jira: LT-22673.

Notes:

  • Slots don't wrap inside themselves. A long entry moves whole to the next line; one wider than the line scrolls inside its slot.

Future work:

  • Fix Ctrl+Z and Ctrl+Y. Presently, within any Avalonia slice, these Un- and Redo text changes within the focused slice or slot, but do not work with the main FieldWorks undo stack.
AI Summary

What changed

  • New FwReversalEntriesField control (FwAvalonia, LCModel-free). It shows one group per visible analysis-writing-system reversal index, labelled with the writing system's abbreviation.
    • Entries sit on one wrapping line, separated by bars, followed by an add slot that fills the rest of the line.
    • Typing in the add slot opens another add slot right away.
    • Each entry's forms in other writing systems show as a read-only suffix.
    • Keys:
      • Left/Right at a slot's edge (Ctrl allowed) move into the next slot, across groups, mirrored in right-to-left groups.
      • Up/Down move between visual lines at the same horizontal position, across groups; that position holds across a run of them.
      • Home/End go to the ends of the visual line; with Ctrl, to the first and last slot of the whole field.
      • Tab/Shift+Tab visit every slot, then continue to the next or previous slice. (Legacy: tab navigates between slices)
      • An arrow pressed with a selection collapses it and goes no further, the way a text box does; Shift+arrow still extends.
      • Enter does nothing; Escape discards unsaved text.
    • Ctrl+click, or the right-click "Show in Reversal Index", jumps to the entry, or for a subentry its main entry, in the Reversal Index tool.
  • Saving (ReversalDetailEditContext, plugin side)
    • The field saves once, when focus leaves it, as one undo step ("Undo change to Reversal Entries").
    • Each changed row finds or creates its colon chain (body: arm: hand, LT-4665) and links the deepest entry; an existing entry is never renamed.
    • Emptying a row unlinks its entry. The entry is deleted if it has no senses and no subentries left, and so is each parent that leaves empty.
    • Rows are matched in NFD, so accented text finds its stored entry.
    • No reversal index is created just to show a group (LT-4480). Entries in hidden writing systems are left alone.
  • Shared detail-view plumbing (general mechanisms, recorded in the control exemplar map):
    • DetailField.ControlFactory now receives the render-time SliceFactoryContext; plugins reach it as SlicePluginBuildContext.Render (jump callback, abbreviation-column width).
    • Plugin rows honour visibility="ifdata"; the row is hidden when the sense has no entries.
    • DetailEditContextBase.AddPendingEditFlush, with DetailEditContextHolder.Settle() flushing first. An editor that saves only on focus loss still gets its edits in when the host saves with focus inside it (navigation, refresh, tool switch).
    • New theme token DataTree.CaretAllowance.

Reviewer notes

  • Most complex parts: the two-phase batch commit in ReversalDetailEditContext.TryCommitRows, row-key rebinding after a commit, and add-slot growth and removal in FwReversalEntriesField.
  • A Devin review round is folded in: the stale Up/Down column and a failed batch leaving half-written links are fixed. A save-hook leak on section toggles is deferred -- no data is at risk today, and the full fix also wants the detail view to dispose replaced editors.
  • A failed batch now closes the host's edit session, discarding whatever else it held. That is the policy the holder already applies to an edit that fails validation.

Validation

  • build.ps1 -CommentHygiene -TokenHygiene: clean.
  • FwAvaloniaTests: 839 of 840 passed, 0 failed, 1 skipped.
  • xWorksTests: 1718 of 1726 passed, 0 failed; the rest are tests that don't run by default.
  • New headless control tests and LCModel tests cover grouping, commit batching, cascade deletion, NFD, undo/redo, RTL, keyboard navigation (arrows, Home/End, Tab -- including inside a real DataTree), selection handling, a failed batch closing the session, and the host settle flush.
  • The author tested this manually, but before the preflight fixes and the rebase onto LT-22688. A manual re-check is still wanted.
Preflight review details

Code Review Summary

Branch: feature/LT-22673-Avalonia-convert-reversal-slice
Base: origin/main (b6cbf8e, after the in-review rebase)
Date: 2026-09-25
Review model: Claude Opus 5.5
Files changed: 22 (5 commits; the Tab change is b2d6b92)

Overview

The author's purpose: port the sense-side Reversal Entries slice
(ReversalIndexEntrySlice, field ReferringReversalIndexEntries) to the
Avalonia detail view with WinForms parity, replacing the reduced-scope
ReversalIndexEntryPlugin (Jira LT-22673). The new LCModel-free
FwReversalEntriesField control shows one group per visible analysis
reversal index, with separator-barred slots on one wrapping line, an add slot
that grows as you type, arrow/Home/End navigation across slots, and a
Ctrl+click / context-menu jump to the Reversal Index tool. The plugin's
ReversalDetailEditContext resolves colon chains find-or-create (LT-4665),
never renames an existing entry, unlinks emptied rows, and cascades deletion
of emptied ancestors. The row hides when the sense has no entries (ifdata).

The analysis found that the first design committed row by row and on the
field's own focus loss. That lost data when a user swapped or shifted text
between slots, and when the host saved or re-showed while focus was still in
the field. It also missed Unicode normalization and a deleted-sense guard.
All of these were fixed during the review.

Contract/API Changes

  • New IReversalEntryEditing (FwAvalonia.Detail): TryCommitRows (batch),
    TryCommitRow, IssueAddRowKey, TryResolveMainEntryGuid. It is
    obtained with ctx as IReversalEntryEditing; the core IDetailEditContext
    is unchanged.
  • New public types FwReversalEntriesField, DetailReversalGroup,
    DetailReversalRow, DetailReversalAlternative.
  • DetailField.ControlFactory is now Func<SliceFactoryContext, Control>
    (was a parameterless factory); SliceFactory.CreateCustom passes the
    render context through.
  • SlicePluginBuildContext gains Render (the SliceFactoryContext) and
    VisibleWritingSystems.
  • DetailEditContextBase gains AddPendingEditFlush / FlushPendingEdits;
    DetailEditContextHolder.Settle() now flushes before checking IsOpen.
  • New theme token DataTree.CaretAllowance, exposed as
    FwAvaloniaDensity.CaretAllowance.
  • New resx strings ReversalShowInReversalIndex, ReversalAddEntryName.

Findings

Critical - Must address before merge

None.

Important - Should address before merge

  • Row-by-row commits lose data when text moves between slots
    (ReversalIndexEntryPlugin.cs, FwReversalEntriesField.cs). Swapping two
    slots' text, or shifting text up a slot, deleted an entry that another row
    was about to take over, because each row released its old entry before the
    next row linked. (fixed during review: the control sends every changed
    slot as one TryCommitRows batch; the plugin links every target first,
    then unlinks only released entries no row still shows. Tests:
    SwappingTwoRows_KeepsBothEntriesLinked,
    ShiftingTextUpARow_DeletesOnlyTheEntryNoRowKeeps.)
  • Saves and re-shows run out of order with the field's focus-loss
    commit
    (FwReversalEntriesField.cs, DetailEditContextHolder.cs).
    Escape (the view's cancel) still saved the typed text on the focus loss that
    followed the re-show, and a navigation, refresh or tool switch settled the
    session before the field had staged its edits, so they were lost or landed
    after the recompose. (fixed during review: Escape restores every slot to
    its saved text; the plugin registers CommitPendingEdits with the host
    context, and Settle() flushes it before deciding whether a session is
    open. Tests: Escape_RestoresEverySlotsSavedText_AndSavesNothing,
    CommitPendingEdits_SavesWhileFocusIsStillInside,
    Settling_SavesWhatTheFieldHolds_WhileFocusIsStillInIt.)
  • No Unicode normalization when matching typed text to stored forms
    (ReversalIndexEntryPlugin.cs). Precomposed typed text never matched
    decomposed stored forms, so an unchanged row looked changed and a
    find-or-create made a duplicate entry. (fixed during review: SplitForms,
    ChainMatches and FindDeepest compare NFD forms. Test:
    PrecomposedTyping_MatchesADecomposedStoredForm.)
  • No guard for a deleted sense, and write exceptions go unhandled
    (ReversalIndexEntryPlugin.cs). (fixed during review: TryCommitRows and
    TryResolveMainEntryGuid return false/null for an invalid sense; a failed
    write logs, restores the row bindings and returns false. Test:
    ACommitForADeletedSense_ChangesNothing.)
  • Branch was behind origin/main, which adds
    IDetailEditContext.TryResetReferenceOrder and conflicts in
    ReversalIndexEntryPlugin.cs
    (e278320). (fixed during review:
    rebased onto origin/main, resolved the conflict, and added
    TryResetReferenceOrder => false to ReversalDetailEditContext and the
    test fake. The branch was already on origin at 87d1b66, so it needs a
    force-push with lease.)

Minor - Consider

  • SlicePluginBuildContext was growing one constructor parameter per
    render-time service
    (SlicePlugins.cs). (fixed during review: it takes
    the SliceFactoryContext as Render instead of separate link-callback and
    column-width parameters.)
  • A write that throws inside an already-open session leaves its partial
    writes in that session.
    Stage rolls back only a session it opened
    itself. The row bindings are restored, but the next settle still validates
    and commits whatever the failed batch wrote. This needs an unexpected LCModel
    exception mid-batch to happen.
  • Flush registrations build up per rendered control. Each render of
    the row registers another CommitPendingEdits on the same context. A
    disposed or unchanged control's flush does nothing, and the list lives only
    as long as one compose, so this is bounded but not tidy.

Devin review (2026-09-30, head db4e96f)

Five bugs and one flag, all verified against the code before acting.

  • Severe: Ctrl+Z undid the wrong step while a slot held typed text. The
    undo guard checked for an open session, which this field opens only on
    focus loss. (fixed in the working tree, then stashed at the author's
    request: LT-22673-undo-guard-flush-20260930,
    59870327c59915d3668bee6cf06998f108bd4681. The branch is again without this
    fix.)
  • Shift+Up/Down left a stale column for the next plain arrow. Took
    Devin's one-line fix: any arrow with a modifier ends the run. Test:
    AModifiedArrow_RestartsThePositionUpAndDownNavigateBy.
  • A failed batch could leave half-written links. A batch that throws
    now closes the host session outright, discarding whatever else it held --
    the policy the holder already applies to an edit that fails validation. The
    row scan moved inside the same guard, since it reads an index that may be
    deleted. Test: AFailedBatch_ClosesTheSession_LeavingNothingToSave.
  • Escape leaves two empty add slots until the re-show (author
    confirmed this is not a problem)
  • Deferred: a section toggle registers another save hook and keeps the
    discarded control.
    Traced: a discarded control's text already matches
    what was saved, so nothing is written; it is a leak with a latent hazard.
    The full fix also wants the detail view to dispose replaced editors, which
    is beyond this branch.
  • The key-map comment exceeds the 200-character budget (the repo's
    own checker raises the budget to 600 in dense branching code and passes it
    clean)

Author's own change in the same round: an arrow pressed with a selection now
collapses it and goes no further, at the end the arrow points at, mirrored in
a right-to-left group; the next press moves. Shift+arrow still extends.

Required Validation / Evidence

Run on the rebased branch, with the in-review fixes staged:

  • .\build.ps1 -CommentHygiene -TokenHygiene: succeeded, 0 warnings,
    0 errors; comment hygiene and token hygiene clean. (Two auto-rewrapped
    comment fragments from the first build were reflowed by hand.)
  • .\test.ps1 -CommentHygiene -TokenHygiene -SkipNative -TestProject Src/Common/FwAvalonia/FwAvaloniaTests: 823 total, 822 passed, 0 failed,
    1 skipped.
  • .\test.ps1 -CommentHygiene -TokenHygiene -SkipNative -TestProject Src/xWorks/xWorksTests: 1725 total, 1717 passed, 0 failed (the rest are
    explicit or ignored tests that don't run by default).
  • git diff --cached --check: clean.
  • After the Devin round (a09aae9): FwAvaloniaTests 840 total, 839 passed,
    0 failed, 1 skipped; xWorksTests 1726 total, 1718 passed, 0 failed; both
    hygiene checks clean; gitlint clean.
  • Still needed: a manual re-check in FieldWorks. The author confirmed a full
    manual pass, but it came before the in-review fixes and the rebase (which
    brought in LT-22688 tab navigation). Worth re-checking: swapping two slots,
    Escape after typing, switching entries with focus in a slot, and an
    accented form typed into an add slot where the entry already exists.
  • gitlint on origin/main..HEAD (the 3 rebased commits): clean. fb15242
    and the commit for the staged Tab change still need the same check;
    "fix misstaging" and the "in progress" title may be worth rewording or
    squashing before the PR.

Ticket: LT-22673 exists and covers the user-visible change.

Positive Observations

  • The control is LCModel-free and tested headlessly through a recording
    fake; the LCModel side has its own fixture against real data, including
    undo/redo, cascade deletion, homographs, RTL and bidi forms.
  • Commits ride the host's fenced session, so one field visit is one undo
    step labelled with the field ("Undo change to Reversal Entries").
  • No reversal index is created just to show a group (LT-4480), and entries
    in hidden writing systems are left alone.
  • ifdata on plugin rows and render-time host services are general
    mechanisms, recorded in the control exemplar map for later slices.

Interview Notes

  • Purpose: the author accepted the proposed purpose statement as written.
  • Findings 1-4: the author asked for all four to be fixed now.
  • Finding 5: the author chose to rebase onto origin/main and force-push later.
  • Minor (SlicePluginBuildContext): the author asked for the change now.
  • Manual testing: the author said "Yes, fully". This came before the
    in-review fixes; see Required Validation.
  • Walkthrough of the most complex part: "I'm not sure."
    Author does not understand: the most complex parts of the change
    (field-level save on focus loss, row-key rebinding after commit, add-slot
    growth).
  • Author notes for reviewers:
    • Up and down arrow keys don't move between slots yet.
    • Slots don't wrap inside themselves. A long entry moves whole to a new
      line. If one entry is wider than the line (unlikely), the user can scroll
      through it with the arrow keys or by click-and-drag selecting.
    • Tab navigation: at interview time Tab didn't work for any Avalonia slice.
      After the rebase brought in LT-22688, the author asked for Tab/Shift+Tab
      to visit every slot (add slots opened while typing included) and to
      leave the field only from its last or first slot. Implemented and
      tested; see In-Review Quality Check.

In-Review Quality Check

INTERVIEW_CHANGES (the author committed these as fb15242, "Claude cleaning
up & fixing tests - in progress"; the Tab change at the end is b2d6b92):

  • IReversalEntryEditing.cs: TryCommitRows batch contract.

  • ReversalIndexEntryPlugin.cs: two-phase batch commit, NFD matching,
    sense guard, failure logging with binding restore, flush registration,
    TryResetReferenceOrder, render context through Render.

  • FwReversalEntriesField.cs: per-slot saved-text state, one batch per
    field visit, public CommitPendingEdits, Escape revert.

  • DetailEditContextBase.cs, DetailEditContextHolder.cs: pending-edit
    flush hook and the flush in Settle().

  • SlicePlugins.cs, DetailComposer.cs: SlicePluginBuildContext.Render.

  • Tests: FwReversalEntriesFieldTests.cs (batch count, Escape, explicit
    flush, fake updates), ReversalEntriesComposeTests.cs (swap, shift,
    other-writing-system forms, NFD, deleted sense, settle flush; idempotent
    AddAnalysisWs), LexemeEditorInventoryTests.cs (render context passed
    through).

  • FwReversalEntriesField.cs (requested after the rebase): Tab and
    Shift+Tab step slot by slot in reading order and fall through to the
    view's tab walk only at the field's ends; a slot opened while typing takes
    its row's tab index so the walk can leave from it. Six new tests, one of
    them inside a real DataTree. FwAvaloniaTests afterwards: 829 total,
    828 passed, 0 failed, 1 skipped; hygiene clean. xWorksTests not re-run
    (no xWorks change).

The build, both hygiene checks, and both test projects pass on the rebased
branch (see above).

Suggested Review Focus

  • ReversalDetailEditContext.TryCommitRows: two-phase link/unlink and
    the stillWanted set, especially with cascade deletion.
  • DetailEditContextHolder.Settle() flushing before the IsOpen check:
    every host save path now stages the field's held edits first.
  • FwReversalEntriesField focus-loss logic (FocusIsInside, add-slot
    removal) and the Escape revert that leaves the key unhandled for the view.
  • Row-key rebinding after a commit, and add-slot key issuing while typing.
  • Tab handling in WireSlotNavigation alongside LT-22688's row tab
    indexes, and the tab index copied onto slots opened while typing.

🤖 Generated with Claude Code


This change is Reviewable

Devin

papeh and others added 5 commits September 25, 2026 17:44
The reversal field now behaves like the WinForms slice it replaces:

- The whole field saves once, when focus leaves it, as one undo step;
  moving between slots saves nothing.
- Typing into a group's empty slot opens a fresh one after it, so
  several entries can be added in one visit. Each slot gets its own
  row key, so two new slots never overwrite each other. A new slot
  that is emptied again is removed when the user moves on.
- Left and Right (alone or with Ctrl) at a slot's edge move into the
  neighboring slot, mirrored in right-to-left groups; Home and End go
  to the edges of the visual line; Enter does nothing.
- Unlinking an entry also deletes each parent the deletion leaves with
  no senses and no subentries, so shortening "arm: hand: finger" to
  "arm" removes "hand" as well as "finger".
- Slots keep a caret's width (the new DataTree.CaretAllowance token)
  past their text, so the caret shows at the end of a slot and in the
  empty add slot.

Also adds exemplar-map rows for FwReversalEntriesField and the two
plugin patterns it introduced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tab and Shift+Tab now visit each slot in reading order, including add
slots opened while typing, and leave the field only from its last or
first slot, where the detail view's tab order moves on to the next or
previous slice. A slot opened after the view built the row takes the
row's tab index from the slot before it, so Tab can leave from it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

NUnit Tests

    1 files  ±  0      1 suites  ±0   13m 25s ⏱️ +25s
6 431 tests +130  6 346 ✅ +130  85 💤 ±0  0 ❌ ±0 
6 440 runs  +130  6 355 ✅ +130  85 💤 ±0  0 ❌ ±0 

Results for commit a09aae9. ± Comparison against base commit 30564cc.

This pull request removes 2 and adds 132 tests. Note that renamed tests count towards both.
SIL.FieldWorks.XWorks.DetailComposerTests ‑ Compose_SenseWithReversalEntry_ComposesEditableReversalRow_NotUnsupported
SIL.FieldWorks.XWorks.DetailComposerTests ‑ ReversalPlugin_EditingAForm_StagesAndCommitsThroughTheEditContext
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ AChangedRow_CommitsOnceWhenItLosesFocus
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ AClick_RestartsThePositionUpAndDownNavigateBy
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ AGroup_ShowsItsEntries_ThenOneAddRow_UnderOneLabel
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ AGroupsSlots_ShareOneLine_WithABarBetweenEachPair
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ ALoneAddSlot_FillsTheWholeLine
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ ALoneAddSlot_HasNoBar
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ ALongEntry_StaysOnOneLine_InsideItsSlot
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ AModifiedArrow_RestartsThePositionUpAndDownNavigateBy
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ ARightToLeftGroup_FlowsRightToLeft
FwAvaloniaTests.Detail.FwReversalEntriesFieldTests ‑ ARunOfUpAndDown_KeepsTheHorizontalPositionItStartedFrom
…

♻️ This comment has been updated with latest results.

@codecov-commenter

codecov-commenter commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.10329% with 96 lines in your changes missing coverage. Please review.
✅ Project coverage is 39.15%. Comparing base (b6cbf8e) to head (a09aae9).
⚠️ Report is 8 commits behind head on main.

Files with missing lines Patch % Lines
...Common/FwAvalonia/Detail/FwReversalEntriesField.cs 90.49% 17 Missing and 37 partials ⚠️
...Works/Avalonia/Plugins/ReversalIndexEntryPlugin.cs 88.44% 10 Missing and 19 partials ⚠️
Src/xWorks/Avalonia/Composer/DetailComposer.cs 57.14% 5 Missing and 4 partials ⚠️
Src/xWorks/Avalonia/DetailEditContextHolder.cs 60.00% 4 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1163      +/-   ##
==========================================
+ Coverage   39.01%   39.15%   +0.14%     
==========================================
  Files        1520     1524       +4     
  Lines      352805   353834    +1029     
  Branches    40692    40911     +219     
==========================================
+ Hits       137632   138550     +918     
- Misses     185879   185921      +42     
- Partials    29294    29363      +69     
Files with missing lines Coverage Δ
Src/Common/FwAvalonia/Detail/DetailModel.cs 78.55% <100.00%> (ø)
Src/Common/FwAvalonia/Detail/FwFieldControls.cs 83.98% <100.00%> (+0.52%) ⬆️
Src/Common/FwAvalonia/Detail/SliceFactory.cs 95.45% <100.00%> (ø)
Src/Common/FwAvalonia/FwAvaloniaDensity.cs 96.61% <100.00%> (+0.05%) ⬆️
Src/Common/FwAvalonia/FwAvaloniaStrings.cs 98.71% <100.00%> (+0.03%) ⬆️
...AvaloniaTheme/Tokens/DataTree/DataTreeTokens.axaml 100.00% <100.00%> (ø)
Src/xWorks/Avalonia/DetailEditContextBase.cs 72.41% <100.00%> (+7.02%) ⬆️
Src/xWorks/Avalonia/Plugins/SlicePlugins.cs 98.18% <100.00%> (+0.14%) ⬆️
Src/xWorks/Avalonia/DetailEditContextHolder.cs 81.81% <60.00%> (-3.26%) ⬇️
Src/xWorks/Avalonia/Composer/DetailComposer.cs 70.66% <57.14%> (+0.41%) ⬆️
... and 2 more

... and 30 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.

papeh and others added 2 commits September 30, 2026 15:15
A batch that throws now closes the host's edit session, so whatever it
wrote before the failure cannot reach a later save. The row scan moved
inside the same guard: it reads a reversal index that may be deleted,
and that throw used to escape to the caller.

Arrow keys no longer navigate from a stale position. A modified arrow
ends a run of Up and Down, and an arrow pressed with a selection
collapses it, at the end the arrow points at, and goes no further. The
next press moves.

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.

2 participants