Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
561dd40
First pass at LT-22673
papeh Sep 24, 2026
bf68622
fix misstaging
papeh Sep 24, 2026
fb90fb2
LT-22673: Grow, navigate and cascade-clean the reversal entry slots
papeh Sep 25, 2026
fb15242
Claude cleaning up & fixing tests - in progress
papeh Sep 25, 2026
b2d6b92
LT-22673: Tab through every reversal entry slot
papeh Sep 28, 2026
db4e96f
Connect up and down arrows and Ctrl+Home and Ctrl+End
papeh Sep 30, 2026
a09aae9
LT-22673: Close a failed reversal batch and steady arrow navigation
papeh Sep 30, 2026
78c87ed
LT-22673: Drop four reversal tests that prove nothing of their own
papeh Oct 1, 2026
6b2962b
LT-22673: Keep only the live control's save hook for a row
papeh Oct 1, 2026
86b8157
LT-22673: Cancel a failed reversal batch through the view
papeh Oct 1, 2026
622cf15
LT-22673: Dispose the detail editors a view replaces
papeh Oct 1, 2026
92093e1
LT-22673: Move from the drawn caret after a keyboard selection
papeh Oct 1, 2026
c3d9db7
LT-22673: Settle held reversal edits before an Undo runs
papeh Oct 2, 2026
2a50804
LT-22673: Spend the Undo on a held edit even when saving it fails
papeh Oct 2, 2026
02431f6
LT-22673: Hold a reversal edit for one Undo, not every one
papeh Oct 2, 2026
081de97
LT-22673: Fail a reversal batch whose row cannot be resolved
papeh Oct 2, 2026
ede5c45
Rename instances of SliceFactoryContext from Render to RenderContext
papeh Oct 6, 2026
27fe7a7
LT-22673: Hold refreshes while a reversal slot holds typed text
papeh Oct 6, 2026
db9c7c7
Address code review comment
papeh Oct 6, 2026
1cf7df5
LT-22673: Release a held refresh when an edit ends unchanged
papeh Oct 6, 2026
bc20ea9
Merge branch main into feature/LT-22673-Avalonia-convert-reversal-slice
papeh Oct 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ migration burden.
| --- | --- | --- |
| FwTextBox (41), LabeledMultiStringControl (5), MultiStringSlice (13) | `FwMultiWsTextField` — per-WS font, RTL, keyboard, abbreviation gutter, staged edits | `Src/Common/FwAvalonia/Detail/FwFieldControls.cs`; dialog usage: `InsertEntryDlgView.axaml` (lexeme form + gloss) |
| FwMultiParaTextBox (1) / StTextSlice | `FwStructuredTextField` (multi-paragraph StText) | `Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs` |
| ReversalIndexEntrySliceView (sense Reversal Entries; a RootSite-embedded free-text list) | `FwReversalEntriesField`: one wrapping line of free-text slots per writing-system group, separator bars, a live-growing add slot, colon-separated subentry chains, and one save (one undo step) when focus leaves the field; row edits go through the `IReversalEntryEditing` sub-capability | `Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs`; LCModel side: `Src/xWorks/Avalonia/Plugins/ReversalIndexEntryPlugin.cs` |
| TreeCombo (27) + PopupTree/PopupTreeManager (36) | popup tree picker | `Src/Common/FwAvalonia/FwPosChooser.cs` |
| FeatureStructureTreeView (3) + `MsaInflectionFeatureListDlg` / `PhonologicalFeatureChooserDlg` | `FwFeatureStructureEditor` — LCModel-free `FsFeatStruc` tree editor: complex features expand to nested features, closed features to symbolic-value radios (one per feature) + a "None of the above" unspecified radio, plus inline create-feature / add-value affordances | `Src/Common/FwAvaloniaDialogs/FwFeatureStructureEditor.cs`; composed in `MSAGroupBox.cs` and the feature chooser (`FeatureChooserDialog*`) |
| SimpleListChooser (26) / ReallySimpleListChooser (22) | `ChooserDialogView`/`ViewModel` (input/result DTOs, single- and multi-select) | `Src/Common/FwAvaloniaDialogs/ChooserDialog*` |
Expand Down Expand Up @@ -70,6 +71,8 @@ migration burden.
| Headless ViewModel/view tests | `FwAvaloniaDialogsTests/EntryGoDialogTests.cs` shapes; launcher-over-real-cache: `LcmLinkMsaDialogLauncherTests.cs` |
| List-editor jump from a chooser row ("Edit the … list") | `DetailGearChrome.CreateConfigureGear` (row gear → `DetailLinkRequest` → `FollowLink`); links built by `DetailComposer.CreateChooserLinks`. **Approved divergence:** the detail view synthesizes the link for ANY possibility-list row the layout left linkless and puts it on the row, where legacy synthesizes only for `autoCustom` and puts it inside the chooser dialog. Reason and approver are in the `CreateChooserLinks` doc — do not "fix" it back to the legacy trigger. |
| Adding to a reference-vector field | `FwReferenceVectorField`'s "+" opens `FwOptionChooser` in multi-select; the per-item right-click removes. **Approved divergence:** current members ride as UNAVAILABLE keys, so they list greyed and un-toggleable rather than pre-checked; legacy's `SimpleListChooser` pre-checks them and treats the dialog as setting the whole membership. "+" means add, and the row owns removal. Reason and approver are in the `FwReferenceVectorField` class doc. |
| A slice plugin that needs host services when it builds its control (for example a jump to another tool) | `DetailField.ControlFactory` receives the render-time `SliceFactoryContext`; `SlicePluginBuildContext.RenderContext.LinkRequested` → `RecordEditView.OnDetailLinkRequested` (saves pending edits, then posts `FollowLink`). Consumer: `ReversalIndexEntryPlugin` |
| `visibility="ifdata"` on a custom (plugin) slice | the composer checks for data before building the plugin row (`DetailComposer.PluginFieldHasData`: an empty list field hides the row; a field it can't resolve or classify still shows). Plugins need no hook of their own |

## 3. Gap register — the first implementation becomes the exemplar

Expand Down
2 changes: 1 addition & 1 deletion Build/mkall.targets
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@
AssemblyFile="$(LcmBuildTasksDir)/SIL.LCModel.Build.Tasks.dll"
/>
<!-- dir-fwoutputCommon is set by the Setup target, so it is empty when top-level
items are evaluated. Target attributes expand at run time and see the real path. -->
items are evaluated. Target attributes expand at run time and see the real path. -->
<Target
Name="GenerateCellarConstants"
DependsOnTargets="CopyCellarBaseConstants;CopyDlls"
Expand Down
18 changes: 16 additions & 2 deletions Src/Common/FwAvalonia/AvaloniaHostControlBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,20 @@ protected void RaiseDetailInteractionCompleted()
/// <summary>Swaps the hosted Avalonia content and shows the control.</summary>
protected void SetHostContent(Avalonia.Controls.Control content)
{
Host.Content = content;
ReplaceContent(content);
Show();
}

// Every swap builds new content, so the outgoing content is never shown again. It is
// disposed only once out of the host, so a focus loss its removal raises still lands.
private void ReplaceContent(Avalonia.Controls.Control content)
{
var outgoing = Host.Content;
Host.Content = content;
if (!ReferenceEquals(outgoing, content))
(outgoing as IDisposable)?.Dispose();
}

/// <summary>The current Avalonia content, or null.</summary>
protected Avalonia.Controls.Control CurrentContent =>
Host.Content as Avalonia.Controls.Control;
Expand Down Expand Up @@ -126,6 +136,10 @@ protected override void Dispose(bool disposing)
_companionStrip.Controls.RemoveAt(i);
}
}
// An owner settles its edit session before disposing this control, so disposing the
// content only releases its editors.
if (disposing)
(Host?.Content as IDisposable)?.Dispose();
base.Dispose(disposing);
}

Expand Down Expand Up @@ -180,7 +194,7 @@ public void ShowContextMenu(IReadOnlyList<DetailMenuItem> items,

public void ShowMessage(string message)
{
Host.Content = new Avalonia.Controls.TextBlock { Text = message ?? string.Empty };
ReplaceContent(new Avalonia.Controls.TextBlock { Text = message ?? string.Empty });
Show();
}

Expand Down
40 changes: 37 additions & 3 deletions Src/Common/FwAvalonia/Detail/DataTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ namespace SIL.FieldWorks.Common.FwAvalonia.Detail
/// undo step per field, no Save/Cancel buttons. Validation failures show inline and block the
/// commit; Escape rolls the session back. Without a context the view is read-only display.
/// </summary>
public sealed class DataTree : UserControl, IDetailPopupSink
public sealed class DataTree : UserControl, IDetailPopupSink, IDisposable
{
private readonly IDetailEditContext _editContext;
private readonly Action<string> _writingSystemFocused;
Expand All @@ -58,6 +58,10 @@ public sealed class DataTree : UserControl, IDetailPopupSink
// The vector rows of the shown fields, and the one whose item is current (at most one
// per view), so re-show continuity can read and restore that selection cheaply.
private readonly List<FwReferenceVectorField> _vectors = new List<FwReferenceVectorField>();
// The editors the shown rows hold that attach handlers of their own, disposed when a
// rebuild replaces them or the view itself is disposed.
private readonly List<IDisposable> _editors = new List<IDisposable>();
private bool _disposed;

/// <summary>The reference-vector row that has a current item; null when none
/// does.</summary>
Expand Down Expand Up @@ -225,6 +229,10 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null,
{
if (_editContext != null && _editContext.IsOpen)
OnSave();
// An edit that changed nothing opens no session, so no completion releases a
// refresh held while its text was unsubmitted.
else if (!HasUnsubmittedText)
DeliverWhenIdle();
}, Avalonia.Interactivity.RoutingStrategies.Bubble);

// A click on another row autosaves and re-shows, rebuilding controls between press
Expand Down Expand Up @@ -334,6 +342,22 @@ private static void ApplyRowTabIndex(Control root, int row)
/// <summary>The detail model this view renders.</summary>
public DetailModel Model { get; }

/// <summary>
/// Disposes the editors the view holds, detaching every handler they attached. The host
/// calls this once the view is out of its window and never shown again, since a
/// disposed editor ignores what is typed into it.
/// </summary>
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
var editors = _editors.ToArray();
_editors.Clear();
foreach (var editor in editors)
editor.Dispose();
}

/// <summary>
/// Raised after a commit or cancel completed, so the host can re-resolve and re-show the
/// detail view from current domain state.
Expand All @@ -352,7 +376,8 @@ private static void ApplyRowTabIndex(Control root, int row)
/// question from <see cref="IsInteractionInFlight"/>: this one must not defer the
/// view's own completion, which is what discards the text, only an external refresh.
/// </summary>
public bool HasUnsubmittedText => _vectors.Any(vector => vector.HasUnstagedText);
public bool HasUnsubmittedText
=> _editors.OfType<IUnstagedTextHolder>().Any(editor => editor.HasUnstagedText);

/// <summary>
/// Raised when the view goes idle again -- the click finished and no picker it
Expand Down Expand Up @@ -479,7 +504,13 @@ private void DeliverWhenIdle()
// expanded.
private void RebuildItems()
{
var replaced = _editors.ToArray();
_editors.Clear();
_form.Items.Clear();
// Only once the form has let go of them, so a focus loss their removal raises still
// reaches their own handlers and stages what they hold.
foreach (var editor in replaced)
editor.Dispose();
_vectors.Clear();
_labelBlocks.Clear();
SelectedVector = null;
Expand Down Expand Up @@ -677,6 +708,8 @@ private FieldContent AddField(int row, DetailField field)
AutomationProperties.SetName(labelBlock, field.Label ?? field.Field ?? string.Empty);
ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // the label text is its own tip
var editor = CreateEditor(field, automationId);
if (editor is IDisposable disposable)
_editors.Add(disposable);
editor.Margin = new Thickness(0, 0, 0, FwAvaloniaDensity.FieldSpacing);

// Every reachable control in a row shares its TabIndex, so Avalonia visits them in
Expand Down Expand Up @@ -902,6 +935,7 @@ private Control CreateEditor(DetailField field, string automationId)
// Legacy labels each alternative of a MultiStringSlice and leaves a StringSlice's
// single value unlabelled, so the gutter follows the row's own kind.
showWritingSystemAbbreviation: field.IsMultiStringRow,
wsAbbrevColumnWidth: _wsAbbrevColumnWidth));
wsAbbrevColumnWidth: _wsAbbrevColumnWidth,
cancel: _editContext == null ? (Action)null : OnCancel));
}
}
9 changes: 5 additions & 4 deletions Src/Common/FwAvalonia/Detail/DetailModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1580,7 +1580,7 @@ public DetailField(
int objectHvo = 0,
string ghostPrompt = null,
IReadOnlyList<DetailChoiceOption> items = null,
Func<Control> controlFactory = null,
Func<SliceFactoryContext, Control> controlFactory = null,
Func<string, IReadOnlyList<DetailChoiceOption>> searchOptions = null,
IReadOnlyList<DetailChooserLink> chooserLinks = null,
IReadOnlyList<DetailParagraph> paragraphs = null,
Expand Down Expand Up @@ -1816,10 +1816,11 @@ public DetailField(
/// <summary>
/// For a <see cref="DetailFieldKind.Custom"/> row: the
/// deferred control factory the claiming plugin supplied via the composer. The view invokes
/// it at render time and places the returned control in the value column; null (or a
/// failing factory) renders the unsupported row instead. Null for every other kind.
/// it at render time with the host's render context (edit context and callbacks, never
/// null) and places the returned control in the value column; null (or a failing
/// factory) renders the unsupported row instead. Null for every other kind.
/// </summary>
public Func<Control> ControlFactory { get; }
public Func<SliceFactoryContext, Control> ControlFactory { get; }

/// <summary>
/// For a <see cref="DetailFieldKind.ReferenceVector"/> row whose targets are searched rather
Expand Down
22 changes: 14 additions & 8 deletions Src/Common/FwAvalonia/Detail/FwFieldControls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ private void AddValueRow(DetailField field, string automationId,
bool showWritingSystemAbbreviation, DetailWsValue value, double wsAbbrevColumnWidth)
{
var currentRich = value.RichText;
var abbrev = CreateWsAbbrev(value, wsAbbrevColumnWidth);
var abbrev = CreateWsAbbrev(value.WsAbbrev, wsAbbrevColumnWidth);

// Values render flat with no box/fill, and go read-only with a tooltip
// -- instead of corrupting on the first keystroke -- when a run carries
Expand Down Expand Up @@ -758,11 +758,11 @@ private void AddValueRow(DetailField field, string automationId,
// own fixed gutter column (see the row Grid below) so a bold vernacular value can never crowd
// or overlap it. ClipToBounds keeps an unusually long abbreviation inside the gutter width
// rather than bleeding into the value column.
private static TextBlock CreateWsAbbrev(DetailWsValue value, double wsAbbrevColumnWidth)
internal static TextBlock CreateWsAbbrev(string wsAbbrev, double wsAbbrevColumnWidth)
{
var abbrev = new TextBlock
{
Text = value.WsAbbrev,
Text = wsAbbrev,
MinWidth = wsAbbrevColumnWidth,
VerticalAlignment = VerticalAlignment.Top,
Margin = new Thickness(0, 1, FwAvaloniaDensity.WsAbbrevGutter, 0),
Expand All @@ -771,7 +771,7 @@ private static TextBlock CreateWsAbbrev(DetailWsValue value, double wsAbbrevColu
ClipToBounds = true
};
// The gutter clips a long abbreviation, so the full text stays discoverable on hover.
ToolTip.SetTip(abbrev, value.WsAbbrev);
ToolTip.SetTip(abbrev, wsAbbrev);
return abbrev;
}

Expand Down Expand Up @@ -1347,7 +1347,7 @@ private static Action<bool> FindSink(FlyoutBase flyout)
/// false and is unaffected.
/// </summary>
public sealed class FwReferenceVectorField : WrapPanel, IHoverAffordanceProvider,
IDetailItemSelection, IDetailTextSelection, IDisposable
IDetailItemSelection, IDetailTextSelection, IUnstagedTextHolder, IDisposable
{
private readonly List<Control> _affordances = new List<Control>();
// The editor the row's text commands act on: the current item's editor or the typed
Expand Down Expand Up @@ -2078,16 +2078,22 @@ public void DiscardUnstagedText()

private void AddSeparatorBar()
{
var bar = new Border
var bar = CreateSeparatorBar();
Children.Add(bar);
_affordances.Add(bar);
}

/// <summary>The thin vertical bar drawn between the items of an inline list.</summary>
internal static Border CreateSeparatorBar()
{
return new Border
{
Width = FwAvaloniaDensity.SeparatorBarWidth,
Height = FwAvaloniaDensity.IconGlyphSize,
Background = FwAvaloniaDensity.SliceRuleBrush,
Margin = FwAvaloniaDensity.SeparatorBarMargin,
VerticalAlignment = VerticalAlignment.Center
};
Children.Add(bar);
_affordances.Add(bar);
}
}

Expand Down
Loading
Loading