diff --git a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md index 9a70616062..24e11bccdb 100644 --- a/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md +++ b/.claude/skills/fieldworks-winforms-to-avalonia-migration/references/control-exemplar-map.md @@ -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*` | @@ -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.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 diff --git a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs index 3ed7a73fb1..f4189adbb7 100644 --- a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs +++ b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs @@ -79,10 +79,20 @@ protected void RaiseDetailInteractionCompleted() /// Swaps the hosted Avalonia content and shows the control. 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(); + } + /// The current Avalonia content, or null. protected Avalonia.Controls.Control CurrentContent => Host.Content as Avalonia.Controls.Control; @@ -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); } @@ -180,7 +194,7 @@ public void ShowContextMenu(IReadOnlyList 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(); } diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 4b4f6c1171..924073d651 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -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. /// - public sealed class DataTree : UserControl, IDetailPopupSink + public sealed class DataTree : UserControl, IDetailPopupSink, IDisposable { private readonly IDetailEditContext _editContext; private readonly Action _writingSystemFocused; @@ -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 _vectors = new List(); + // 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 _editors = new List(); + private bool _disposed; /// The reference-vector row that has a current item; null when none /// does. @@ -334,6 +338,22 @@ private static void ApplyRowTabIndex(Control root, int row) /// The detail model this view renders. public DetailModel Model { get; } + /// + /// 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. + /// + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + var editors = _editors.ToArray(); + _editors.Clear(); + foreach (var editor in editors) + editor.Dispose(); + } + /// /// Raised after a commit or cancel completed, so the host can re-resolve and re-show the /// detail view from current domain state. @@ -479,7 +499,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; @@ -677,6 +703,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 @@ -851,6 +879,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)); } } diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index f6a02160aa..e623d4bf9e 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1580,7 +1580,7 @@ public DetailField( int objectHvo = 0, string ghostPrompt = null, IReadOnlyList items = null, - Func controlFactory = null, + Func controlFactory = null, Func> searchOptions = null, IReadOnlyList chooserLinks = null, IReadOnlyList paragraphs = null, @@ -1795,10 +1795,11 @@ public DetailField( /// /// For a 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. /// - public Func ControlFactory { get; } + public Func ControlFactory { get; } /// /// For a row whose targets are searched rather diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 92b74cbfd7..7b64574941 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -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 @@ -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), @@ -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; } @@ -1884,7 +1884,15 @@ public void DiscardUnstagedText() private void AddSeparatorBar() { - var bar = new Border + var bar = CreateSeparatorBar(); + Children.Add(bar); + _affordances.Add(bar); + } + + /// The thin vertical bar drawn between the items of an inline list. + internal static Border CreateSeparatorBar() + { + return new Border { Width = FwAvaloniaDensity.SeparatorBarWidth, Height = FwAvaloniaDensity.IconGlyphSize, @@ -1892,8 +1900,6 @@ private void AddSeparatorBar() Margin = FwAvaloniaDensity.SeparatorBarMargin, VerticalAlignment = VerticalAlignment.Center }; - Children.Add(bar); - _affordances.Add(bar); } } diff --git a/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs b/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs new file mode 100644 index 0000000000..d586c9f392 --- /dev/null +++ b/Src/Common/FwAvalonia/Detail/FwReversalEntriesField.cs @@ -0,0 +1,880 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Controls.Documents; +using Avalonia.Controls.Presenters; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Layout; +using Avalonia.Media; +using Avalonia.VisualTree; + +namespace SIL.FieldWorks.Common.FwAvalonia.Detail +{ + /// + /// One of a reversal entry's forms in a writing system other than its index's own. + /// + public sealed class DetailReversalAlternative + { + /// Creates the alternative. + /// The writing system's abbreviation; empty shows the form + /// alone. + /// The form. + /// The writing system's font; null or empty keeps the + /// default. + public DetailReversalAlternative(string wsAbbrev, string text, string fontFamily) + { + WsAbbrev = wsAbbrev; + Text = text; + FontFamily = fontFamily; + } + + public string WsAbbrev { get; } + + public string Text { get; } + + public string FontFamily { get; } + } + + /// + /// One row of a : an entry linked to the sense, or the + /// group's add row. + /// + public sealed class DetailReversalRow + { + /// Creates the row. + /// The opaque identity the edit context issued for this row. + /// The entry's form, a subentry's ancestors colon-joined before + /// it; empty for an add row. + /// True for the group's add row. + /// The entry's forms in other writing systems; null means + /// none. + public DetailReversalRow(string rowKey, string text, bool isAddSlot, + IReadOnlyList otherWsForms = null) + { + RowKey = rowKey; + Text = text ?? string.Empty; + IsAddSlot = isAddSlot; + OtherWsForms = otherWsForms ?? Array.Empty(); + } + + public string RowKey { get; } + + public string Text { get; } + + public bool IsAddSlot { get; } + + /// The entry's forms in other writing systems, in display order; never + /// null. + public IReadOnlyList OtherWsForms { get; } + } + + /// + /// One reversal index's rows: the sense's entries in that index, followed by its add row. + /// + public sealed class DetailReversalGroup + { + /// Creates the group. + /// The index's writing system tag. + /// The index's writing system abbreviation, the group's + /// label. + /// The writing system's font; null or empty keeps the + /// default. + /// Whether the writing system is right-to-left. + /// The group's rows in display order; null means none. + public DetailReversalGroup(string wsTag, string wsAbbrev, string fontFamily, bool rightToLeft, + IReadOnlyList rows) + { + WsTag = wsTag; + WsAbbrev = wsAbbrev; + FontFamily = fontFamily; + RightToLeft = rightToLeft; + Rows = rows ?? Array.Empty(); + } + + public string WsTag { get; } + + public string WsAbbrev { get; } + + public string FontFamily { get; } + + public bool RightToLeft { get; } + + public IReadOnlyList Rows { get; } + } + + /// + /// FieldWorks-owned editor for a sense's reversal entries. Each reversal index is a group + /// labeled with its writing system abbreviation: one wrapping line of editable slots, one + /// per linked entry and a final empty one for adding, with a bar between neighboring + /// slots. Typing into the empty slot opens a fresh one after it, and an add slot emptied + /// again disappears once the user moves on. Moving between slots commits nothing; when + /// focus leaves the field, every changed + /// slot commits through , as one edit. The rows are a + /// snapshot that the host rebuilds after its save. Right-clicking a row offers "Show in + /// Reversal Index", and Ctrl+click runs it directly. An edit context without + /// shows the rows read-only. + /// + public sealed class FwReversalEntriesField : StackPanel, IDisposable + { + private readonly List _teardown = new List(); + private readonly List _slots = new List(); + private readonly List _groups = new List(); + private readonly IReversalEntryEditing _editing; + private readonly Action _navigationRequested; + private bool _disposed; + // The horizontal position a run of Up/Down navigates by, so ragged lines do not walk + // the caret sideways; null until one starts, and again as soon as anything else moves it. + private double? _lineNavigationX; + + /// Builds the editor. + /// The field label, used in accessible names. + /// The field's automation id, the prefix of every row's + /// id. + /// The groups in display order; null means none. + /// The detail view's edit context; null shows the rows + /// read-only. + /// Called with a group's writing system tag when + /// one of its rows gains focus; null disables that. + /// Called with a row key to show that row's entry + /// in the Reversal Index tool; null leaves out the jump. + /// Width of the abbreviation column; null uses the + /// default. + public FwReversalEntriesField(string label, string automationId, + IReadOnlyList groups, IDetailEditContext editContext, + Action writingSystemFocused = null, Action navigationRequested = null, + double? wsAbbrevColumnWidth = null) + { + Spacing = FwAvaloniaDensity.RowSpacing; + var name = label ?? automationId; + AutomationProperties.SetAutomationId(this, automationId); + AutomationProperties.SetName(this, name); + _editing = editContext as IReversalEntryEditing; + _navigationRequested = _editing == null ? null : navigationRequested; + + var abbrevWidth = wsAbbrevColumnWidth ?? FwAvaloniaDensity.WsAbbrevWidth; + foreach (var group in groups ?? Array.Empty()) + Children.Add(CreateGroup(name, automationId, group, writingSystemFocused, abbrevWidth)); + + // A click puts the caret somewhere of its own, so the next Up or Down starts from + // there rather than from wherever the last one was heading. + EventHandler pressed = (s, e) => _lineNavigationX = null; + AddHandler(InputElement.PointerPressedEvent, pressed, RoutingStrategies.Tunnel, + handledEventsToo: true); + _teardown.Add(() => RemoveHandler(InputElement.PointerPressedEvent, pressed)); + } + + // A text-sized editor clips its own caret at the end, and fits none at all when empty, so + // a slot measures a little wider than its text. + private sealed class SlotTextBox : TextBox + { + protected override Type StyleKeyOverride => typeof(TextBox); + + protected override Size MeasureOverride(Size availableSize) + { + var size = base.MeasureOverride(availableSize); + return new Size(size.Width + FwAvaloniaDensity.CaretAllowance, size.Height); + } + } + + // Wraps its children onto lines the way a horizontal WrapPanel does, then widens the last + // child, the group's add slot, across whatever its line has left. + private sealed class SlotLinePanel : Panel + { + protected override Size MeasureOverride(Size availableSize) + { + double width = 0, height = 0, lineWidth = 0, lineHeight = 0; + foreach (var child in Children) + { + child.Measure(availableSize); + var size = child.DesiredSize; + if (lineWidth > 0 && lineWidth + size.Width > availableSize.Width) + { + width = Math.Max(width, lineWidth); + height += lineHeight; + lineWidth = 0; + lineHeight = 0; + } + lineWidth += size.Width; + lineHeight = Math.Max(lineHeight, size.Height); + } + width = Math.Max(width, lineWidth); + height += lineHeight; + return new Size(double.IsInfinity(availableSize.Width) ? width : availableSize.Width, height); + } + + protected override Size ArrangeOverride(Size finalSize) + { + var lines = new List>(); + var line = new List(); + double lineWidth = 0; + foreach (var child in Children) + { + var childWidth = child.DesiredSize.Width; + if (line.Count > 0 && lineWidth + childWidth > finalSize.Width) + { + lines.Add(line); + line = new List(); + lineWidth = 0; + } + line.Add(child); + lineWidth += childWidth; + } + if (line.Count > 0) + lines.Add(line); + + var last = Children.Count > 0 ? Children[Children.Count - 1] : null; + double y = 0; + foreach (var current in lines) + { + var lineHeight = current.Max(child => child.DesiredSize.Height); + double x = 0; + foreach (var child in current) + { + var childWidth = child.DesiredSize.Width; + if (ReferenceEquals(child, last)) + childWidth = Math.Max(childWidth, finalSize.Width - x); + child.Arrange(new Rect(x, y, childWidth, lineHeight)); + x += childWidth; + } + y += lineHeight; + } + return finalSize; + } + } + + // One group's live line of slots, which grows as the user types into its last one. + private sealed class GroupState + { + public DetailReversalGroup Group; + public string GroupId; + public string Label; + public Action WritingSystemFocused; + public SlotLinePanel Slots; + public TextBox TrailingAdd; + public int AddedSlots; + } + + private Control CreateGroup(string label, string automationId, DetailReversalGroup group, + Action writingSystemFocused, double abbrevWidth) + { + var state = new GroupState + { + Group = group, + GroupId = automationId + "." + group.WsTag, + Label = label, + WritingSystemFocused = writingSystemFocused, + // The group's slots run together on one wrapping line, a bar between each pair, + // the add slot last. + Slots = new SlotLinePanel + { + Background = FwAvaloniaDensity.TransparentBrush, + FlowDirection = group.RightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight + } + }; + _groups.Add(state); + var rows = state.Slots; + AutomationProperties.SetAutomationId(rows, state.GroupId); + for (var i = 0; i < group.Rows.Count; i++) + { + var row = group.Rows[i]; + AppendSlot(state, row, row.IsAddSlot ? state.GroupId + ".Add" : state.GroupId + "." + i); + } + + if (_editing != null) + { + // An empty add slot is barely wider than its caret, so a click anywhere on the + // group's free space starts typing there. + EventHandler pressed = (s, e) => + { + if (!ReferenceEquals(e.Source, rows) || state.TrailingAdd == null) + return; + state.TrailingAdd.Focus(); + e.Handled = true; + }; + rows.PointerPressed += pressed; + _teardown.Add(() => rows.PointerPressed -= pressed); + } + + var abbrev = FwMultiWsTextField.CreateWsAbbrev(group.WsAbbrev, abbrevWidth); + var grid = new Grid { ColumnDefinitions = new ColumnDefinitions("Auto,*") }; + Grid.SetColumn(abbrev, 0); + Grid.SetColumn(rows, 1); + grid.Children.Add(abbrev); + grid.Children.Add(rows); + return grid; + } + + // Adds a slot to the end of the group's line, after a bar when the line is not empty. + private void AppendSlot(GroupState state, DetailReversalRow row, string rowId) + { + if (state.Slots.Children.Count > 0) + state.Slots.Children.Add(FwReferenceVectorField.CreateSeparatorBar()); + state.Slots.Children.Add(CreateRow(state, row, rowId, out var box)); + if (row.IsAddSlot) + state.TrailingAdd = box; + } + + // The first keystroke in the last add slot opens a fresh one after it, so several entries + // can be typed in one visit. Nothing is saved until focus leaves the field. + private void Grow(GroupState state, string addRowKey) + { + var key = _editing.IssueAddRowKey(addRowKey); + if (key == null) + return; + state.AddedSlots++; + // The view gives a row's controls its tab index when it builds the row, so a slot + // opened later takes the index from the slot before it, or Tab could not leave it. + var tabIndex = KeyboardNavigation.GetTabIndex(state.TrailingAdd); + AppendSlot(state, new DetailReversalRow(key, string.Empty, true), + state.GroupId + ".Add" + state.AddedSlots); + KeyboardNavigation.SetTabIndex(state.TrailingAdd, tabIndex); + } + + // Drops an add slot the user emptied again before it was saved, with the bar that joined + // it to the line. + private static void RemoveSlot(GroupState state, Control slot) + { + var children = state.Slots.Children; + var index = children.IndexOf(slot); + if (index < 0) + return; + children.RemoveAt(index); + if (index > 0) + children.RemoveAt(index - 1); + else if (children.Count > 0) + children.RemoveAt(0); + } + + private Control CreateRow(GroupState state, DetailReversalRow row, string rowId, out TextBox box) + { + var group = state.Group; + var label = state.Label; + var writingSystemFocused = state.WritingSystemFocused; + var editor = new SlotTextBox + { + Text = row.Text, + Padding = FwAvaloniaDensity.EditorPadding, + MinHeight = 0, + AcceptsReturn = false, + IsReadOnly = _editing == null, + FlowDirection = group.RightToLeft ? FlowDirection.RightToLeft : FlowDirection.LeftToRight, + BorderThickness = new Thickness(0), + Background = FwAvaloniaDensity.TransparentBrush, + TextWrapping = TextWrapping.NoWrap + }; + box = editor; + if (!string.IsNullOrEmpty(group.FontFamily)) + editor.FontFamily = new FontFamily(group.FontFamily); + AutomationProperties.SetAutomationId(editor, rowId); + AutomationProperties.SetName(editor, row.IsAddSlot + ? FwAvaloniaStrings.ReversalAddEntryName(label, group.WsAbbrev) + : label + " " + group.WsAbbrev); + + var slotState = new SlotState(row, editor); + _slots.Add(slotState); + + // The control this method returns; the slot removal below needs it. + Control slot = null; + if (_editing != null) + { + // Runs before the host's own focus-loss save, which bubbles up from this box. + // Moving between slots stages nothing, so the host saves only once focus leaves. + EventHandler lost = (s, e) => + { + if (row.IsAddSlot && !ReferenceEquals(editor, state.TrailingAdd) + && string.IsNullOrEmpty(editor.Text) && string.IsNullOrEmpty(slotState.Committed)) + { + RemoveSlot(state, slot); + } + if (!FocusIsInside()) + CommitAll(); + }; + editor.LostFocus += lost; + _teardown.Add(() => editor.LostFocus -= lost); + + if (row.IsAddSlot) + { + EventHandler grow = (s, e) => + { + if (ReferenceEquals(editor, state.TrailingAdd) && !string.IsNullOrEmpty(editor.Text)) + Grow(state, row.RowKey); + }; + editor.TextChanged += grow; + _teardown.Add(() => editor.TextChanged -= grow); + } + } + + WireSlotNavigation(editor, group.RightToLeft); + + if (writingSystemFocused != null && !string.IsNullOrEmpty(group.WsTag)) + { + EventHandler got = (s, e) => writingSystemFocused(group.WsTag); + editor.GotFocus += got; + _teardown.Add(() => editor.GotFocus -= got); + } + + if (_navigationRequested != null) + WireNavigation(editor, row); + + var suffix = CreateOtherWsSuffix(row, rowId + ".OtherWs"); + if (suffix == null) + { + slot = editor; + return slot; + } + var panel = new StackPanel + { + Orientation = Orientation.Horizontal, + Background = FwAvaloniaDensity.TransparentBrush + }; + panel.Children.Add(editor); + panel.Children.Add(suffix); + slot = panel; + return slot; + } + + // Keys that treat the field's slots as one text. Enter does nothing. Tab and Shift+Tab + // visit every slot in reading order, add slots included, and leave the field only from + // its last or first slot. Left and Right, alone or with Ctrl, at a slot's edge move into + // the neighboring slot, across groups (in a right-to-left group the start is on the + // right). Up and Down move between visual lines at the same horizontal position. Home + // and End go to the edges of the current visual line, and with Ctrl to the ends of the + // whole field. + private void WireSlotNavigation(TextBox editor, bool rightToLeft) + { + EventHandler keyDown = (s, e) => + { + // Modified arrows can move the caret without joining a plain Up/Down run. + if ((e.Key != Key.Up && e.Key != Key.Down) + || e.KeyModifiers != KeyModifiers.None) + _lineNavigationX = null; + if (e.Key == Key.Enter) + { + e.Handled = true; + return; + } + if (e.Key == Key.Tab && (e.KeyModifiers & ~KeyModifiers.Shift) == KeyModifiers.None) + { + var editors = SlotEditors(); + var next = editors.IndexOf(editor) + + (e.KeyModifiers == KeyModifiers.Shift ? -1 : 1); + // Past either end the view's own tab order moves on to the neighboring row. + if (next < 0 || next >= editors.Count) + return; + editors[next].Focus(NavigationMethod.Tab, e.KeyModifiers); + e.Handled = true; + return; + } + if (e.Key == Key.Escape) + { + // Left unhandled, so the view still cancels; its re-show then finds nothing + // to save. + RevertPendingEdits(); + return; + } + if ((e.Key == Key.Home || e.Key == Key.End) + && (e.KeyModifiers == KeyModifiers.None || e.KeyModifiers == KeyModifiers.Control)) + { + var toStart = e.Key == Key.Home; + if (e.KeyModifiers == KeyModifiers.Control) + MoveToFieldEdge(toStart); + else + MoveToLineEdge(editor, toStart); + e.Handled = true; + return; + } + // An arrow that is not extending a selection collapses it and goes no further, + // the way a text box does; the position a run navigated by goes with it. + if (IsArrow(e.Key) && (e.KeyModifiers & KeyModifiers.Shift) == KeyModifiers.None + && editor.SelectionStart != editor.SelectionEnd) + { + CollapseSelection(editor, e.Key, rightToLeft); + _lineNavigationX = null; + e.Handled = true; + return; + } + if ((e.Key == Key.Up || e.Key == Key.Down) && e.KeyModifiers == KeyModifiers.None) + { + // Past the first or last line the key is left alone, for the view to answer. + if (MoveToNeighboringLine(editor, e.Key == Key.Up)) + e.Handled = true; + return; + } + if ((e.KeyModifiers & ~KeyModifiers.Control) != KeyModifiers.None + || (e.Key != Key.Left && e.Key != Key.Right)) + { + return; + } + var toward = (e.Key == Key.Left) != rightToLeft ? -1 : 1; + var length = (editor.Text ?? string.Empty).Length; + var caret = DrawnCaret(editor); + if (toward < 0 ? caret != 0 : caret != length) + return; + + var slots = SlotEditors(); + var index = slots.IndexOf(editor) + toward; + if (index < 0 || index >= slots.Count) + return; + PlaceCaret(slots[index], toward < 0); + e.Handled = true; + }; + editor.AddHandler(InputElement.KeyDownEvent, keyDown, RoutingStrategies.Tunnel); + _teardown.Add(() => editor.RemoveHandler(InputElement.KeyDownEvent, keyDown)); + } + + // Ctrl+Home and Ctrl+End treat the whole field as one text: its very first slot and its + // very last, whichever group they are in. + private void MoveToFieldEdge(bool toStart) + { + var slots = SlotEditors(); + if (slots.Count > 0) + PlaceCaret(toStart ? slots[0] : slots[slots.Count - 1], !toStart); + } + + // Up and Down move to the neighboring visual line, keeping the caret's horizontal + // position: the slot under it takes the caret. False at the field's first or last line. + private bool MoveToNeighboringLine(TextBox editor, bool up) + { + var lines = SlotLines(); + var index = lines.FindIndex(line => + line.Any(slot => ReferenceEquals(SlotEditor(slot), editor))); + var target = index + (up ? -1 : 1); + if (index < 0 || target < 0 || target >= lines.Count) + return false; + _lineNavigationX = _lineNavigationX ?? CaretX(editor); + var slot = NearestSlot(lines[target], _lineNavigationX); + if (slot == null) + return false; + PlaceCaretAtX(SlotEditor(slot), _lineNavigationX); + return true; + } + + // The field's slots grouped into visual lines, top line first and each line in + // left-to-right order: a group's panel puts one line's slots at the same top, and the + // groups stack in order. + private List> SlotLines() + { + var lines = new List>(); + foreach (var state in _groups) + { + var slots = state.Slots.Children.Where(child => SlotEditor(child) != null); + foreach (var line in slots.GroupBy(child => child.Bounds.Y).OrderBy(line => line.Key)) + lines.Add(line.OrderBy(child => child.Bounds.X).ToList()); + } + return lines; + } + + // The line's slot at horizontal position x, or the nearest one when x falls on a bar + // between slots or past the line's end. The first slot when there is no position to + // match, null for a line without slots. + private Control NearestSlot(IReadOnlyList line, double? x) + { + if (!x.HasValue) + return line.FirstOrDefault(); + Control nearest = null; + var shortest = double.MaxValue; + foreach (var slot in line) + { + var left = slot.TranslatePoint(new Point(0, 0), this)?.X; + if (!left.HasValue) + continue; + var right = left.Value + slot.Bounds.Width; + var distance = x.Value < left.Value + ? left.Value - x.Value + : x.Value > right ? x.Value - right : 0; + if (distance < shortest) + { + nearest = slot; + shortest = distance; + } + } + return nearest ?? line.FirstOrDefault(); + } + + // The caret's horizontal position in the field's own coordinates, or null while the + // slot has no laid-out text to measure it against. + private double? CaretX(TextBox editor) + { + var presenter = SlotPresenter(editor); + var layout = presenter?.TextLayout; + if (layout == null) + return null; + var length = (editor.Text ?? string.Empty).Length; + var caret = layout.HitTestTextPosition(Math.Min(Math.Max(DrawnCaret(editor), 0), length)); + return presenter.TranslatePoint(new Point(caret.X, 0), this)?.X; + } + + // Puts the caret on the character nearest horizontal position x, or at the slot's end + // when there is no position to match or no laid-out text to match it against. + private void PlaceCaretAtX(TextBox target, double? x) + { + target.Focus(); + var end = (target.Text ?? string.Empty).Length; + var caret = end; + var presenter = SlotPresenter(target); + var layout = presenter?.TextLayout; + if (x.HasValue && layout != null) + { + var local = this.TranslatePoint(new Point(x.Value, 0), presenter); + if (local.HasValue) + { + var hit = layout.HitTestPoint(new Point(local.Value.X, 0)); + caret = Math.Min(hit.TextPosition + (hit.IsTrailing ? 1 : 0), end); + } + } + SetCaret(target, caret); + } + + private static TextPresenter SlotPresenter(TextBox editor) + => editor?.GetVisualDescendants().OfType().FirstOrDefault(); + + // Where the caret is drawn. A keyboard selection draws it at its moving end but leaves + // the text box's own caret index at the anchor, so that index can lag behind. + private static int DrawnCaret(TextBox editor) + => SlotPresenter(editor)?.CaretIndex ?? editor.CaretIndex; + + // Home goes to the start of the first slot on the editor's visual line, End to the end of + // the last; the group's wrap panel puts every slot of one line at the same top. + private void MoveToLineEdge(TextBox editor, bool toStart) + { + foreach (var state in _groups) + { + var slots = state.Slots.Children + .Select(child => new { Slot = child, Editor = SlotEditor(child) }) + .Where(pair => pair.Editor != null) + .ToList(); + var current = slots.FirstOrDefault(pair => ReferenceEquals(pair.Editor, editor)); + if (current == null) + continue; + var line = slots.Where(pair => pair.Slot.Bounds.Y.Equals(current.Slot.Bounds.Y)).ToList(); + PlaceCaret((toStart ? line.First() : line.Last()).Editor, !toStart); + return; + } + } + + private static void PlaceCaret(TextBox target, bool atEnd) + { + target.Focus(); + SetCaret(target, atEnd ? (target.Text ?? string.Empty).Length : 0); + } + + private static bool IsArrow(Key key) + => key == Key.Left || key == Key.Right || key == Key.Up || key == Key.Down; + + // Up and Down leave the caret where it is drawn, so the next press moves from there. Left + // and Right land on the end they point at, mirrored in a right-to-left group. + private static void CollapseSelection(TextBox editor, Key key, bool rightToLeft) + { + if (key == Key.Up || key == Key.Down) + { + SetCaret(editor, DrawnCaret(editor)); + return; + } + SetCaret(editor, (key == Key.Left) != rightToLeft + ? Math.Min(editor.SelectionStart, editor.SelectionEnd) + : Math.Max(editor.SelectionStart, editor.SelectionEnd)); + } + + private static void SetCaret(TextBox target, int caret) + { + target.CaretIndex = caret; + target.SelectionStart = caret; + target.SelectionEnd = caret; + // Setting the index the text box already holds leaves the drawn caret where it was. + SlotPresenter(target)?.MoveCaretToTextPosition(caret, false); + } + + // A slot is its editor, or a panel holding the editor and its read-only suffix. + private static TextBox SlotEditor(Control slot) + => slot as TextBox ?? (slot as Panel)?.Children.OfType().FirstOrDefault(); + + // The slot editors in reading order: group by group, each line from its first slot. + private List SlotEditors() + { + var editors = new List(); + foreach (var state in _groups) + { + foreach (var child in state.Slots.Children) + { + var editor = SlotEditor(child); + if (editor != null) + editors.Add(editor); + } + } + return editors; + } + + // A slot's row, its editor, and the text the model holds for it, so an unchanged slot + // never stages. + private sealed class SlotState + { + public SlotState(DetailReversalRow row, TextBox editor) + { + Row = row; + Editor = editor; + Committed = row.Text; + } + + public DetailReversalRow Row { get; } + + public TextBox Editor { get; } + + public string Committed { get; set; } + + public string Text => Editor.Text ?? string.Empty; + } + + /// + /// Stages every slot changed since the last save, as one change on the host's edit + /// session. The field otherwise stages only when focus leaves it, so a host that saves + /// while focus is still inside (on navigation, a refresh, or a tool switch) calls this + /// first. Does nothing when read-only, disposed, or unchanged. + /// + public void CommitPendingEdits() => CommitAll(); + + private void CommitAll() + { + if (_editing == null || _disposed) + return; + var changed = _slots.Where(slot => slot.Text != slot.Committed).ToList(); + if (changed.Count == 0) + return; + var edits = changed + .Select(slot => new KeyValuePair(slot.Row.RowKey, slot.Text)) + .ToList(); + if (_editing.TryCommitRows(edits)) + { + foreach (var slot in changed) + slot.Committed = slot.Text; + } + } + + // Puts every slot back to the text the model holds, dropping what was typed. + private void RevertPendingEdits() + { + foreach (var slot in _slots) + { + if (slot.Text != slot.Committed) + slot.Editor.Text = slot.Committed; + } + } + + // Focus has already moved when a slot's LostFocus runs, so this tells a move to another + // slot from leaving the field. + private bool FocusIsInside() + { + var focused = TopLevel.GetTopLevel(this)?.FocusManager?.GetFocusedElement() as Visual; + return focused != null && (ReferenceEquals(focused, this) || this.IsVisualAncestorOf(focused)); + } + + // The jump commits every slot first, so a form typed into the add row exists when shown. + private void WireNavigation(TextBox box, DetailReversalRow row) + { + Action jump = () => + { + CommitAll(); + _navigationRequested(row.RowKey); + }; + + EventHandler pressed = (s, e) => + { + if (string.IsNullOrEmpty(box.Text) + || !e.KeyModifiers.HasFlag(KeyModifiers.Control) + || !e.GetCurrentPoint(box).Properties.IsLeftButtonPressed) + { + return; + } + jump(); + e.Handled = true; + }; + box.AddHandler(InputElement.PointerPressedEvent, pressed, RoutingStrategies.Tunnel); + + var show = new MenuItem { Header = FwAvaloniaStrings.ReversalShowInReversalIndex }; + EventHandler click = (s, e) => jump(); + show.Click += click; + var menu = new MenuFlyout { Items = { show } }; + EventHandler opening = (s, e) => show.IsEnabled = !string.IsNullOrEmpty(box.Text); + menu.Opening += opening; + var previousMenu = box.ContextFlyout; + box.ContextFlyout = menu; + var popupTeardown = PopupReporting.Wire(menu); + _teardown.Add(() => + { + box.RemoveHandler(InputElement.PointerPressedEvent, pressed); + show.Click -= click; + menu.Opening -= opening; + popupTeardown(); + box.ContextFlyout = previousMenu; + }); + } + + // Read-only, in display order: [abbrev form, abbrev form]. Null when the entry has none. + private static Control CreateOtherWsSuffix(DetailReversalRow row, string automationId) + { + if (row.OtherWsForms.Count == 0) + return null; + + var block = new TextBlock + { + TextWrapping = TextWrapping.Wrap, + VerticalAlignment = VerticalAlignment.Top, + Padding = FwAvaloniaDensity.EditorPadding, + FlowDirection = FlowDirection.LeftToRight + }; + block.Inlines.Add(new Run("[")); + for (var i = 0; i < row.OtherWsForms.Count; i++) + { + var alternative = row.OtherWsForms[i]; + if (i > 0) + block.Inlines.Add(new Run(", ")); + if (!string.IsNullOrEmpty(alternative.WsAbbrev)) + { + block.Inlines.Add(new Run(alternative.WsAbbrev) + { + FontSize = FwAvaloniaDensity.WsAbbrevFontSize, + Foreground = FwAvaloniaDensity.WsAbbrevBrush + }); + block.Inlines.Add(new Run(" ")); + } + var form = new Run(alternative.Text ?? string.Empty); + if (!string.IsNullOrEmpty(alternative.FontFamily)) + form.FontFamily = new FontFamily(alternative.FontFamily); + block.Inlines.Add(form); + } + block.Inlines.Add(new Run("]")); + AutomationProperties.SetAutomationId(block, automationId); + return block; + } + + /// + /// The count of still-attached handler teardowns; zero after . + /// + public int AttachedHandlerCount => _teardown.Count; + + /// Detaches every wired handler and drops the row menus. Idempotent. + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + foreach (var detach in _teardown) + detach(); + _teardown.Clear(); + Disposed?.Invoke(this, EventArgs.Empty); + } + + /// + /// Raised once, when the field is disposed, so anything that registered it elsewhere, + /// such as a pending-edit flush on the host, can let it go. + /// + public event EventHandler Disposed; + } +} diff --git a/Src/Common/FwAvalonia/Detail/IReversalEntryEditing.cs b/Src/Common/FwAvalonia/Detail/IReversalEntryEditing.cs new file mode 100644 index 0000000000..0a562cfba6 --- /dev/null +++ b/Src/Common/FwAvalonia/Detail/IReversalEntryEditing.cs @@ -0,0 +1,57 @@ +// 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; +using System.Collections.Generic; + +namespace SIL.FieldWorks.Common.FwAvalonia.Detail +{ + /// + /// The row-level editing capability behind , kept off + /// the core so only a context that edits a sense's + /// reversal entries carries it. A caller acquires it with + /// ctx as IReversalEntryEditing and treats a null result as read-only. Row keys are + /// the opaque values the same context issued; an + /// unknown key is rejected. + /// + public interface IReversalEntryEditing + { + /// + /// Stages the text of several rows as one change, opening the edit session only when + /// something changes. Per row: text equal to what the row already shows changes nothing; + /// empty text on an entry row unlinks that entry; other text is split on colons into a + /// chain of entry and subentry forms, and the sense is linked to the deepest entry of + /// that chain, found or created -- an existing entry is never renamed -- in place of the + /// row's old entry. Every row takes its new entry before any old one is let go, and an + /// old entry another row still shows stays linked. An unlinked entry left with no senses + /// and no subentries is deleted, and so is each parent that deletion leaves with + /// neither. Afterwards each key names its row's new entry, or an add row again after an + /// unlink, so a later commit on the same row edits what this one produced. + /// + /// Row key to typed text, one pair per changed row. + /// False, without opening the session, when no row changes or the sense no + /// longer exists; also false when the write fails, which is logged and cancels the + /// session, edits and all, the way the view's own cancel does, rather than leaving a + /// half-written batch to be saved. + bool TryCommitRows(IReadOnlyList> edits); + + /// Stages one row's text: a of that single + /// row. + bool TryCommitRow(string rowKey, string typedText); + + /// + /// Issues the key of another add row in the same reversal index as + /// , for a slot the editor opens while the user types. Each add + /// row needs its own key, since a commit rebinds the key to the entry it produced. + /// + /// The new key, or null for an unknown key. + string IssueAddRowKey(string rowKey); + + /// + /// The guid of the top-level entry to show for a row: the row's own entry, or for a + /// subentry its main entry. Null for an add row or an unknown key. + /// + Guid? TryResolveMainEntryGuid(string rowKey); + } +} diff --git a/Src/Common/FwAvalonia/Detail/SliceFactory.cs b/Src/Common/FwAvalonia/Detail/SliceFactory.cs index d6289f38a3..bc3f2a72a4 100644 --- a/Src/Common/FwAvalonia/Detail/SliceFactory.cs +++ b/Src/Common/FwAvalonia/Detail/SliceFactory.cs @@ -31,7 +31,8 @@ public SliceFactoryContext( IFwClipboard clipboard = null, Action save = null, bool showWritingSystemAbbreviation = true, - double? wsAbbrevColumnWidth = null) + double? wsAbbrevColumnWidth = null, + Action cancel = null) { EditContext = editContext; WritingSystemFocused = writingSystemFocused; @@ -39,6 +40,7 @@ public SliceFactoryContext( LinkRequested = linkRequested; Clipboard = clipboard; Save = save; + Cancel = cancel; ShowWritingSystemAbbreviation = showWritingSystemAbbreviation; WsAbbrevColumnWidth = wsAbbrevColumnWidth ?? FwAvaloniaDensity.WsAbbrevWidth; } @@ -68,6 +70,14 @@ public SliceFactoryContext( /// public Action Save { get; } + /// + /// Cancels the view's open edit session and has the host re-show the view from the + /// domain, as Escape does. An editor that cannot finish a write calls this rather than + /// cancelling the session itself, so no field is left showing text the cancel rolled + /// back. Null on hosts that drive their own sessions. + /// + public Action Cancel { get; } + /// /// Whether a multi-WS text field shows its per-WS abbreviation gutter. The detail pane shows it; /// a dense in-cell editor suppresses it (matching the legacy in-cell editor). @@ -100,7 +110,7 @@ public static Control Build(DetailField field, string automationId, switch (field.Kind) { case DetailFieldKind.Custom: - return CreateCustom(field, automationId); + return CreateCustom(field, automationId, context); case DetailFieldKind.ReferenceVector: // Reference add/remove gestures commit immediately (legacy chooser-dialog behavior): the // staged session would otherwise sit open -- LCModel broadcasts PropChanged @@ -164,7 +174,8 @@ private static Control CreateLiteral(DetailField field, string automationId) // the value column. A missing, null-returning, or throwing factory // degrades to the unsupported row -- never a crash, never silently // blank. - private static Control CreateCustom(DetailField field, string automationId) + private static Control CreateCustom(DetailField field, string automationId, + SliceFactoryContext context) { if (field.ControlFactory == null) { @@ -175,7 +186,7 @@ private static Control CreateCustom(DetailField field, string automationId) try { - var control = field.ControlFactory(); + var control = field.ControlFactory(context); if (control == null) { System.Diagnostics.Debug.WriteLine( diff --git a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs index 4327872a7d..7ba9c13a5e 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaDensity.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaDensity.cs @@ -308,6 +308,10 @@ public static class FwAvaloniaDensity /// reference-vector items. public static double SeparatorBarWidth => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_SeparatorBarWidth); + /// Room a text-sized editor keeps past its text for the caret and a final + /// glyph's overhang. + public static double CaretAllowance => FwThemeResources.RequireDouble(GeneratedTokenKeys.DataTree_CaretAllowance); + /// /// Corner radius for a compact bordered host (option/POS picker frame, MSA/feature group /// box); pairs with and diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs index 7cd4da6fd8..5b44a6fa95 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.cs @@ -302,5 +302,16 @@ public static string StructuredTextParagraphName(string fieldLabel, int paragrap /// The empty-choice label a list chooser leads with when the field allows no value. /// English must match the legacy launchers' ksNullLabel so both frameworks show the same word. public static string ChooserEmptyItemLabel => Text("FwAvalonia.Chooser.EmptyItemLabel"); + + // ----- Reversal Entries field. APPEND-ONLY. ----- + + /// The row menu command that jumps to the row's entry in the Reversal Index + /// tool. + public static string ReversalShowInReversalIndex => Text("FwAvalonia.Reversal.ShowInReversalIndex"); + + /// Screen-reader name for a writing system's empty add row. + /// {0} = the field label, {1} = the writing system abbreviation. + public static string ReversalAddEntryName(string fieldLabel, string wsAbbrev) + => string.Format(Text("FwAvalonia.Reversal.AddEntryName"), fieldLabel, wsAbbrev); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx index 4250706a24..424db73dd1 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaStrings.resx +++ b/Src/Common/FwAvalonia/FwAvaloniaStrings.resx @@ -238,4 +238,11 @@ <Empty> + + Show in Reversal Index (Ctrl-Click) + + + {0} {1} new entry + {0} = the field label, {1} = the writing system abbreviation + \ No newline at end of file diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/FwReversalEntriesFieldTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/FwReversalEntriesFieldTests.cs new file mode 100644 index 0000000000..ef71df9dfb --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/FwReversalEntriesFieldTests.cs @@ -0,0 +1,1207 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Automation; +using Avalonia.Controls; +using Avalonia.Headless; +using Avalonia.Headless.NUnit; +using Avalonia.Input; +using Avalonia.Interactivity; +using Avalonia.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests.Detail +{ + /// + /// The Reversal Entries control () rendered + /// headlessly: its groups and rows, when a row commits, and the row jump. A recording + /// fake stands in for the edit context, whose LCModel side is tested on its own. + /// + [TestFixture] + public class FwReversalEntriesFieldTests + { + private const string FieldId = "Reversal"; + + // Records every row commit and jump, in order, so a test can check which came first. + private sealed class RecordingReversalContext : IDetailEditContext, IReversalEntryEditing + { + public readonly List Events = new List(); + + public int Batches; + + public bool TryCommitRows(IReadOnlyList> edits) + { + Batches++; + foreach (var edit in edits) + Events.Add("commit " + edit.Key + "=" + edit.Value); + return true; + } + + public bool TryCommitRow(string rowKey, string typedText) + => TryCommitRows(new[] { new KeyValuePair(rowKey, typedText) }); + + private int _issued; + + public string IssueAddRowKey(string rowKey) => "en-add" + ++_issued; + + public Guid? TryResolveMainEntryGuid(string rowKey) => Guid.Empty; + + public bool IsOpen => false; + + public bool TrySetText(DetailField field, string ws, string value) => false; + + public bool TrySetRichText(DetailField field, string ws, DetailRichTextValue value) => false; + + public bool TrySetOption(DetailField field, string optionKey) => false; + + public bool TryAddReferenceItem(DetailField field, string optionKey) => false; + + public bool TryRemoveReferenceItem(DetailField field, string optionKey) => false; + + public bool TryMoveReferenceItem(DetailField field, string optionKey, bool forward) => false; + + public bool TryResetReferenceOrder(DetailField field) => false; + + public IReadOnlyList Validate() => Array.Empty(); + + public void Commit() + { + } + + public void Cancel() + { + } + } + + private static DetailReversalGroup English(params string[] forms) + { + var rows = forms.Select((f, i) => new DetailReversalRow("en" + i, f, false)).ToList(); + rows.Add(new DetailReversalRow("en-add", string.Empty, true)); + return new DetailReversalGroup("en", "Eng", null, false, rows); + } + + private static (FwReversalEntriesField Field, TextBox Other, Window Window) Show( + IDetailEditContext context, List jumps, params DetailReversalGroup[] groups) + { + Action navigate = null; + if (jumps != null) + navigate = key => jumps.Add(key); + var field = new FwReversalEntriesField("Reversal Entries", FieldId, groups, context, + navigationRequested: navigate); + var other = new TextBox(); + var panel = new StackPanel(); + panel.Children.Add(field); + panel.Children.Add(other); + var window = new Window { Content = panel, Width = 420, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (field, other, window); + } + + private static T Find(Control root, string automationId) where T : Control + => root.GetVisualDescendants().OfType() + .FirstOrDefault(c => AutomationProperties.GetAutomationId(c) == automationId); + + private static void TypeAndLeave(TextBox box, string text, TextBox other) + { + box.Focus(); + box.Text = text; + other.Focus(); + Dispatcher.UIThread.RunJobs(); + } + + private static void CtrlClick(Window window, Control control) + { + var point = control.TranslatePoint(new Point(2, 2), window); + Assert.That(point, Is.Not.Null, "the click target must be attached and laid out"); + window.MouseDown(point.Value, MouseButton.Left, RawInputModifiers.Control); + window.MouseUp(point.Value, MouseButton.Left, RawInputModifiers.Control); + Dispatcher.UIThread.RunJobs(); + } + + [AvaloniaTest] + public void AGroup_ShowsItsEntries_ThenOneAddRow_UnderOneLabel() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + + Assert.That(Find(field, "Reversal.en.0").Text, Is.EqualTo("dwelling")); + Assert.That(Find(field, "Reversal.en.1").Text, Is.EqualTo("abode")); + Assert.That(Find(field, "Reversal.en.Add").Text, Is.Empty); + Assert.That(field.GetVisualDescendants().OfType().Count(t => t.Text == "Eng"), Is.EqualTo(1), + "the abbreviation labels the group once, not every entry"); + } + + [AvaloniaTest] + public void AGroupsSlots_ShareOneLine_WithABarBetweenEachPair() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var group = Find(field, "Reversal.en"); + var first = Find(field, "Reversal.en.0"); + var second = Find(field, "Reversal.en.1"); + var add = Find(field, "Reversal.en.Add"); + + Assert.That(group.Children.OfType().Count(), Is.EqualTo(2), + "three slots, so two bars"); + Assert.That(second.TranslatePoint(new Point(0, 0), window).Value.Y, + Is.EqualTo(first.TranslatePoint(new Point(0, 0), window).Value.Y), "short entries share a line"); + Assert.That(add.TranslatePoint(new Point(0, 0), window).Value.X, + Is.GreaterThan(second.TranslatePoint(new Point(0, 0), window).Value.X), "the add slot comes last"); + } + + // A text-sized editor clips a caret at the end of its text, and an empty one has no room + // for a caret at all, so each slot's text area must be wider than its text. + [AvaloniaTest] + public void EverySlot_LeavesRoomForTheCaretAfterItsText() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("Antarctica")); + + foreach (var id in new[] { "Reversal.en.0", "Reversal.en.Add" }) + { + var box = Find(field, id); + box.Focus(); + box.CaretIndex = box.Text?.Length ?? 0; + Dispatcher.UIThread.RunJobs(); + var presenter = box.GetVisualDescendants() + .OfType().Single(); + + Assert.That(presenter.Bounds.Width, + Is.GreaterThanOrEqualTo(presenter.DesiredSize.Width + FwAvaloniaDensity.CaretAllowance), id); + } + } + + [AvaloniaTest] + public void ALongEntry_StaysOnOneLine_InsideItsSlot() + { + var longForm = string.Join(" ", Enumerable.Repeat("dwelling", 30)); + var (field, _, _) = Show(new RecordingReversalContext(), null, English("home", longForm)); + + Assert.That(Find(field, "Reversal.en.1").Bounds.Height, + Is.EqualTo(Find(field, "Reversal.en.0").Bounds.Height), + "text wider than the line does not wrap inside its slot"); + } + + [AvaloniaTest] + public void TheAddSlot_FillsTheRestOfItsLine_AndEntrySlotsDoNot() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, English("dwelling")); + var group = Find(field, "Reversal.en"); + var entry = Find(field, "Reversal.en.0"); + var add = Find(field, "Reversal.en.Add"); + + Assert.That(add.Bounds.Right, Is.EqualTo(group.Bounds.Width).Within(0.5), + "the add slot reaches the end of its line"); + Assert.That(add.Bounds.Width, Is.GreaterThan(add.DesiredSize.Width)); + Assert.That(entry.Bounds.Width, Is.EqualTo(entry.DesiredSize.Width).Within(0.5), + "an entry slot stays as wide as its text"); + } + + [AvaloniaTest] + public void AfterGrowth_OnlyTheNewLastSlotStretches() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English()); + var group = Find(field, "Reversal.en"); + var typed = Find(field, "Reversal.en.Add"); + + typed.Focus(); + typed.Text = "home"; + Dispatcher.UIThread.RunJobs(); + var fresh = Find(field, "Reversal.en.Add1"); + + Assert.That(typed.Bounds.Width, Is.EqualTo(typed.DesiredSize.Width).Within(0.5)); + Assert.That(fresh.Bounds.Right, Is.EqualTo(group.Bounds.Width).Within(0.5)); + } + + [AvaloniaTest] + public void ALoneAddSlot_HasNoBar() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English()); + + Assert.That(Find(field, "Reversal.en").Children.OfType(), Is.Empty); + } + + [AvaloniaTest] + public void ClickingAGroupsFreeSpace_StartsTypingInItsAddSlot() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, English("dwelling")); + var group = Find(field, "Reversal.en"); + var point = group.TranslatePoint(new Point(group.Bounds.Width - 2, 2), window); + + window.MouseDown(point.Value, MouseButton.Left); + window.MouseUp(point.Value, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + + Assert.That(Find(field, "Reversal.en.Add").IsFocused, Is.True); + } + + [AvaloniaTest] + public void EachIndex_IsItsOwnGroup() + { + var french = new DetailReversalGroup("fr", "Fre", null, false, + new[] { new DetailReversalRow("fr0", "maison", false), new DetailReversalRow("fr-add", "", true) }); + + var (field, _, _) = Show(new RecordingReversalContext(), null, English("house"), french); + + Assert.That(Find(field, "Reversal.en"), Is.Not.Null); + Assert.That(Find(field, "Reversal.fr"), Is.Not.Null); + Assert.That(Find(field, "Reversal.fr.0").Text, Is.EqualTo("maison")); + } + + [AvaloniaTest] + public void OtherWritingSystemForms_ShowAsAReadOnlySuffix() + { + var rows = new[] + { + new DetailReversalRow("en0", "house", false, + new[] { new DetailReversalAlternative("EnGB", "houze", null) }), + new DetailReversalRow("en1", "home", false), + new DetailReversalRow("en-add", "", true) + }; + var (field, _, _) = Show(new RecordingReversalContext(), null, + new DetailReversalGroup("en", "Eng", null, false, rows)); + + var suffix = Find(field, "Reversal.en.0.OtherWs"); + Assert.That(suffix, Is.Not.Null); + Assert.That(string.Concat(suffix.Inlines.OfType().Select(r => r.Text)), + Is.EqualTo("[EnGB houze]")); + Assert.That(Find(field, "Reversal.en.1.OtherWs"), Is.Null, "an entry with no alternatives has none"); + Assert.That(Find(field, "Reversal.en.Add.OtherWs"), Is.Null, "an add row has none"); + Assert.That(field.GetVisualDescendants().OfType().Any(b => b.Text?.Contains("houze") == true), + Is.False, "the alternatives are display text, never an editor"); + } + + [AvaloniaTest] + public void AChangedRow_CommitsOnceWhenItLosesFocus() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling")); + var add = Find(field, "Reversal.en.Add"); + + add.Focus(); + add.Text = "h"; + add.Text = "home"; + Assert.That(context.Events, Is.Empty, "typing alone commits nothing"); + other.Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Events, Is.EqualTo(new[] { "commit en-add=home" })); + + add.Focus(); + other.Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(context.Events, Has.Count.EqualTo(1), "leaving again without a change commits nothing more"); + } + + [AvaloniaTest] + public void MovingBetweenSlots_CommitsNothing_UntilFocusLeavesTheField() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling")); + var entry = Find(field, "Reversal.en.0"); + var add = Find(field, "Reversal.en.Add"); + + entry.Focus(); + entry.Text = "abode"; + add.Focus(); + add.Text = "home"; + entry.Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(context.Events, Is.Empty, "focus is still inside the field"); + + other.Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(context.Events, Is.EqualTo(new[] { "commit en0=abode", "commit en-add=home" }), + "leaving the field commits every changed slot, in order"); + Assert.That(context.Batches, Is.EqualTo(1), "the changed slots are saved as one change"); + } + + [AvaloniaTest] + public void Escape_RestoresEverySlotsSavedText_AndSavesNothing() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + var second = Find(field, "Reversal.en.1"); + TypeAndLeave(first, "house", other); + first.Focus(); + first.Text = "home"; + second.Focus(); + second.Text = "hut"; + + Press(second, Key.Escape); + other.Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(first.Text, Is.EqualTo("house"), "the text saved earlier stays"); + Assert.That(second.Text, Is.EqualTo("abode")); + Assert.That(context.Events, Is.EqualTo(new[] { "commit en0=house" }), + "nothing typed since the last save is saved"); + } + + [AvaloniaTest] + public void CommitPendingEdits_SavesWhileFocusIsStillInside() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling")); + var entry = Find(field, "Reversal.en.0"); + entry.Focus(); + entry.Text = "house"; + + field.CommitPendingEdits(); + Assert.That(entry.IsFocused, Is.True); + other.Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Events, Is.EqualTo(new[] { "commit en0=house" }), + "leaving afterwards saves nothing more"); + } + + [AvaloniaTest] + public void TypingIntoTheAddSlot_OpensAFreshOne_WithoutSaving() + { + var context = new RecordingReversalContext(); + var (field, _, _) = Show(context, null, English("dwelling")); + var add = Find(field, "Reversal.en.Add"); + + add.Focus(); + add.Text = "h"; + Dispatcher.UIThread.RunJobs(); + + var fresh = Find(field, "Reversal.en.Add1"); + Assert.That(fresh, Is.Not.Null, "the first keystroke opens another empty slot"); + Assert.That(fresh.Text, Is.Empty); + Assert.That(Find(field, "Reversal.en").Children.OfType().Count(), Is.EqualTo(2), + "the new slot is joined to the line by a bar"); + Assert.That(add.IsFocused, Is.True, "typing continues in the same slot"); + Assert.That(context.Events, Is.Empty, "nothing is saved while typing"); + } + + [AvaloniaTest] + public void FurtherKeystrokes_OpenNoMoreSlots() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English()); + var add = Find(field, "Reversal.en.Add"); + + add.Focus(); + add.Text = "h"; + add.Text = "ho"; + add.Text = "home"; + Dispatcher.UIThread.RunJobs(); + + Assert.That(field.GetVisualDescendants().OfType().Count(), Is.EqualTo(2)); + } + + [AvaloniaTest] + public void EachNewSlot_SavesWithItsOwnKey_WhenFocusLeaves() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English()); + + // TextChanged arrives through the dispatcher, so each keystroke is run before the + // next step. + var add = Find(field, "Reversal.en.Add"); + add.Focus(); + add.Text = "one"; + Dispatcher.UIThread.RunJobs(); + var second = Find(field, "Reversal.en.Add1"); + second.Focus(); + second.Text = "two"; + Dispatcher.UIThread.RunJobs(); + Assert.That(Find(field, "Reversal.en.Add2"), Is.Not.Null, "typing in the new slot opens a third"); + Assert.That(context.Events, Is.Empty); + + other.Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Events, Is.EqualTo(new[] { "commit en-add=one", "commit en-add1=two" }), + "each typed slot saves under its own key; the empty last slot saves nothing"); + } + + [AvaloniaTest] + public void AnAddSlotEmptiedAgain_IsRemovedWhenTheUserMovesOn() + { + var context = new RecordingReversalContext(); + var (field, _, _) = Show(context, null, English("dwelling")); + var add = Find(field, "Reversal.en.Add"); + + add.Focus(); + add.Text = "h"; + Dispatcher.UIThread.RunJobs(); + add.Text = string.Empty; + Dispatcher.UIThread.RunJobs(); + Find(field, "Reversal.en.Add1").Focus(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(Find(field, "Reversal.en.Add"), Is.Null); + Assert.That(Find(field, "Reversal.en").Children.OfType().Count(), Is.EqualTo(1), + "the removed slot takes its bar with it"); + Assert.That(context.Events, Is.Empty); + } + + private static void Press(TextBox box, Key key, KeyModifiers modifiers = KeyModifiers.None) + { + box.RaiseEvent(new KeyEventArgs + { + RoutedEvent = InputElement.KeyDownEvent, + Key = key, + KeyModifiers = modifiers, + Source = box + }); + Dispatcher.UIThread.RunJobs(); + } + + private static void PlaceCaret(TextBox box, int caret) + { + box.Focus(); + box.CaretIndex = caret; + box.SelectionStart = caret; + box.SelectionEnd = caret; + } + + private static DetailReversalGroup French(params string[] forms) + { + var rows = forms.Select((f, i) => new DetailReversalRow("fr" + i, f, false)).ToList(); + rows.Add(new DetailReversalRow("fr-add", string.Empty, true)); + return new DetailReversalGroup("fr", "Fre", null, false, rows); + } + + [AvaloniaTest] + public void LeftAtASlotsStart_MovesToTheEndOfThePreviousSlot() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + PlaceCaret(Find(field, "Reversal.en.1"), 0); + + Press(Find(field, "Reversal.en.1"), Key.Left); + + Assert.That(first.IsFocused, Is.True); + Assert.That(first.CaretIndex, Is.EqualTo("dwelling".Length)); + } + + [AvaloniaTest] + public void RightAtASlotsEnd_MovesToTheStartOfTheNextSlot() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var second = Find(field, "Reversal.en.1"); + var first = Find(field, "Reversal.en.0"); + PlaceCaret(first, "dwelling".Length); + + Press(first, Key.Right); + + Assert.That(second.IsFocused, Is.True); + Assert.That(second.CaretIndex, Is.Zero); + } + + private static void Tab(Window window, bool shift = false) + { + window.KeyPressQwerty(PhysicalKey.Tab, shift ? RawInputModifiers.Shift : RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + } + + private static string FocusedId(Window window) + => window.FocusManager?.GetFocusedElement() is Control focused + ? AutomationProperties.GetAutomationId(focused) + : null; + + [AvaloniaTest] + public void Tab_VisitsEverySlotInOrder_IncludingAddSlotsOpenedWhileTyping() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, + English("dwelling"), French("maison")); + var add = Find(field, "Reversal.en.Add"); + add.Focus(); + add.Text = "home"; + Dispatcher.UIThread.RunJobs(); + Find(field, "Reversal.en.0").Focus(); + + var visited = new List(); + for (var i = 0; i < 4; i++) + { + Tab(window); + visited.Add(FocusedId(window)); + } + + Assert.That(visited, Is.EqualTo(new[] + { + "Reversal.en.Add", "Reversal.en.Add1", "Reversal.fr.0", "Reversal.fr.Add" + })); + } + + [AvaloniaTest] + public void ShiftTab_VisitsTheSlotsInReverse() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, + English("dwelling"), French("maison")); + Find(field, "Reversal.fr.Add").Focus(); + + var visited = new List(); + for (var i = 0; i < 3; i++) + { + Tab(window, shift: true); + visited.Add(FocusedId(window)); + } + + Assert.That(visited, Is.EqualTo(new[] { "Reversal.fr.0", "Reversal.en.Add", "Reversal.en.0" })); + } + + [AvaloniaTest] + public void Tab_MovesMidText_WithoutEditingTheSlot() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + PlaceCaret(first, 3); + + Tab(window); + + Assert.That(FocusedId(window), Is.EqualTo("Reversal.en.1")); + Assert.That(first.Text, Is.EqualTo("dwelling"), "Tab is navigation, never typed text"); + } + + [AvaloniaTest] + public void TabFromTheLastSlot_LeavesTheField_AndSavesIt() + { + var context = new RecordingReversalContext(); + var (field, other, window) = Show(context, null, English("dwelling")); + var entry = Find(field, "Reversal.en.0"); + entry.Focus(); + entry.Text = "house"; + Tab(window); + Assert.That(context.Events, Is.Empty, "moving to the add slot stays inside the field"); + + Tab(window); + + Assert.That(other.IsFocused, Is.True, "Tab from the final add slot goes to the next control"); + Assert.That(context.Events, Is.EqualTo(new[] { "commit en0=house" })); + } + + [AvaloniaTest] + public void ShiftTabFromTheFirstSlot_LeavesTheField() + { + var field = new FwReversalEntriesField("Reversal Entries", FieldId, + new[] { English("dwelling") }, new RecordingReversalContext()); + var before = new TextBox(); + var panel = new StackPanel(); + panel.Children.Add(before); + panel.Children.Add(field); + var window = new Window { Content = panel, Width = 420, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + Find(field, "Reversal.en.0").Focus(); + + Tab(window, shift: true); + + Assert.That(before.IsFocused, Is.True); + } + + // The detail view orders Tab by row, so a slot opened after the view built the row must + // still sort with its row. + [AvaloniaTest] + public void InTheDetailView_TabFromAnAddSlotOpenedWhileTyping_MovesToTheNextRow() + { + var context = new RecordingReversalContext(); + var reversal = new DetailField("Reversal", "Reversal Entries", "ReferringReversalIndexEntries", + null, DetailFieldKind.Custom, EditorClassification.Known, FieldId, null, HostRouting.Inherit, + null, null, null, objectHvo: 1, + controlFactory: render => new FwReversalEntriesField("Reversal Entries", FieldId, + new[] { English("dwelling") }, context)); + var next = new DetailField("Next", "Next", "Next", null, DetailFieldKind.Text, + EditorClassification.Known, "Next", null, HostRouting.Inherit, + new List { new DetailWsValue("vern", "value") }, null, null, objectHvo: 1); + var model = new DetailModel("LexSense", "Normal", new List { reversal, next }, + new List()); + var view = new DataTree(model, editContext: context); + var window = new Window { Content = view, Width = 480, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var add = Find(view, "Reversal.en.Add"); + add.Focus(); + add.Text = "home"; + Dispatcher.UIThread.RunJobs(); + + Tab(window); + Assert.That(FocusedId(window), Is.EqualTo("Reversal.en.Add1")); + Tab(window); + + Assert.That(FocusedId(window), Is.EqualTo("Next.vern")); + } + + [AvaloniaTest] + public void Enter_DoesNothing() + { + var context = new RecordingReversalContext(); + var (field, _, window) = Show(context, null, English("dwelling")); + var box = Find(field, "Reversal.en.0"); + var reachedHost = 0; + window.AddHandler(InputElement.KeyDownEvent, (s, e) => reachedHost++, RoutingStrategies.Bubble); + box.Focus(); + box.Text = "house"; + PlaceCaret(box, 2); + + Press(box, Key.Enter); + Press(box, Key.Enter, KeyModifiers.Control); + + Assert.That(reachedHost, Is.Zero, "Enter never reaches the view, so it saves nothing"); + Assert.That(box.Text, Is.EqualTo("house")); + Assert.That(box.IsFocused, Is.True); + Assert.That(context.Events, Is.Empty); + } + + [AvaloniaTest] + public void HomeAndEnd_GoToTheEdgesOfTheLine_AcrossSlots() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + var middle = Find(field, "Reversal.en.1"); + var add = Find(field, "Reversal.en.Add"); + + PlaceCaret(middle, 2); + Press(middle, Key.Home); + Assert.That(first.IsFocused, Is.True); + Assert.That(first.CaretIndex, Is.Zero); + + PlaceCaret(middle, 2); + Press(middle, Key.End); + Assert.That(add.IsFocused, Is.True, "the line ends with the add slot"); + Assert.That(add.CaretIndex, Is.Zero); + + PlaceCaret(first, 3); + Press(first, Key.End); + PlaceCaret(add, 0); + Press(add, Key.Home); + Assert.That(first.IsFocused, Is.True); + } + + [AvaloniaTest] + public void HomeAndEnd_StayOnTheirOwnLine_WhenTheGroupWraps() + { + var forms = Enumerable.Range(0, 12).Select(i => "dwellingplace" + i).ToArray(); + var (field, _, _) = Show(new RecordingReversalContext(), null, English(forms)); + var boxes = Enumerable.Range(0, forms.Length) + .Select(i => Find(field, "Reversal.en." + i)).ToList(); + var group = Find(field, "Reversal.en"); + double Top(TextBox box) => box.TranslatePoint(new Point(0, 0), group).Value.Y; + var firstLineTop = Top(boxes[0]); + var onSecondLine = boxes.Where(b => Top(b) > firstLineTop).ToList(); + Assert.That(onSecondLine, Is.Not.Empty, "precondition: the group wraps"); + var secondLine = onSecondLine.Where(b => Top(b).Equals(Top(onSecondLine[0]))).ToList(); + + var current = secondLine.Last(); + PlaceCaret(current, 1); + Press(current, Key.Home); + Assert.That(secondLine[0].IsFocused, Is.True, "Home goes to the start of this line, not the group"); + + PlaceCaret(boxes[0], 1); + Press(boxes[0], Key.End); + var firstLine = boxes.Where(b => Top(b).Equals(firstLineTop)).ToList(); + Assert.That(firstLine.Last().IsFocused, Is.True, "End goes to the end of this line"); + Assert.That(firstLine.Last().CaretIndex, Is.EqualTo(firstLine.Last().Text.Length)); + } + + [AvaloniaTest] + public void ModifiedHomeAndEnd_StayInTheSlot() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var middle = Find(field, "Reversal.en.1"); + + PlaceCaret(middle, 2); + Press(middle, Key.Home, KeyModifiers.Shift); + + Assert.That(middle.IsFocused, Is.True); + } + + // Every slot of the field, in build order; a row's read-only suffix is a TextBlock, so + // the text boxes are exactly the slots. + private static List Slots(Control field) + => field.GetVisualDescendants().OfType().ToList(); + + private static TextBox FocusedSlot(Window window) + => window.FocusManager?.GetFocusedElement() as TextBox; + + private static void Click(Window window, Control control) + { + var point = control.TranslatePoint(new Point(2, 2), window); + Assert.That(point, Is.Not.Null, "the click target must be attached and laid out"); + window.MouseDown(point.Value, MouseButton.Left); + window.MouseUp(point.Value, MouseButton.Left); + Dispatcher.UIThread.RunJobs(); + } + + // A group wide enough to wrap, so the field has more than one visual line. + private static (FwReversalEntriesField Field, Window Window, List FirstLine, + List SecondLine) ShowWrappingGroup() + { + var forms = Enumerable.Range(0, 12).Select(i => "dwellingplace" + i).ToArray(); + var (field, _, window) = Show(new RecordingReversalContext(), null, English(forms)); + var group = Find(field, "Reversal.en"); + var slots = Slots(field); + double Top(TextBox box) => box.TranslatePoint(new Point(0, 0), group).Value.Y; + var firstTop = Top(slots[0]); + var below = slots.Where(box => Top(box) > firstTop).ToList(); + Assert.That(below, Is.Not.Empty, "precondition: the group wraps"); + var secondTop = Top(below[0]); + return (field, window, + slots.Where(box => Top(box).Equals(firstTop)).ToList(), + slots.Where(box => Top(box).Equals(secondTop)).ToList()); + } + + [AvaloniaTest] + public void UpAndDown_MoveBetweenTheLinesOfAWrappingGroup() + { + var (_, window, firstLine, secondLine) = ShowWrappingGroup(); + var start = secondLine[0]; + + PlaceCaret(start, 1); + Press(start, Key.Up); + Assert.That(firstLine, Does.Contain(FocusedSlot(window)), "Up moves to the line above"); + + Press(FocusedSlot(window), Key.Down); + Assert.That(secondLine, Does.Contain(FocusedSlot(window)), "Down moves back down"); + } + + [AvaloniaTest] + public void Down_MovesIntoTheNextGroup_AndUpComesBack() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, + English("dwelling"), French("maison")); + var start = Find(field, "Reversal.en.0"); + + PlaceCaret(start, 2); + Press(start, Key.Down); + Assert.That(FocusedId(window), Does.StartWith("Reversal.fr."), + "the next line is the next group's"); + + Press(FocusedSlot(window), Key.Up); + Assert.That(FocusedId(window), Does.StartWith("Reversal.en.")); + } + + [AvaloniaTest] + public void ARunOfUpAndDown_KeepsTheHorizontalPositionItStartedFrom() + { + var (_, window, firstLine, _) = ShowWrappingGroup(); + var start = firstLine.Last(); + PlaceCaret(start, start.Text.Length); + + Press(start, Key.Down); + Press(FocusedSlot(window), Key.Up); + + Assert.That(FocusedSlot(window), Is.SameAs(start), + "the caret returns to where the run started, not to the slot under the line below"); + Assert.That(start.CaretIndex, Is.EqualTo(start.Text.Length)); + } + + [AvaloniaTest] + public void AClick_RestartsThePositionUpAndDownNavigateBy() + { + var (_, window, firstLine, secondLine) = ShowWrappingGroup(); + var start = firstLine.Last(); + PlaceCaret(start, start.Text.Length); + Press(start, Key.Down); + + Click(window, secondLine[0]); + Press(secondLine[0], Key.Up); + + Assert.That(FocusedSlot(window), Is.SameAs(firstLine[0]), + "Up follows the click's own position, not the run it interrupted"); + } + + // A mouse-drag selection cannot be driven headlessly, and a key-made one would clear the + // run by itself. The caret goes at the selection's start, where it does not collapse it. + private static void PlaceSelection(TextBox box, int caret, int start, int end) + { + box.Focus(); + box.SelectionStart = start; + box.SelectionEnd = end; + box.CaretIndex = caret; + Assert.That(box.SelectionStart, Is.Not.EqualTo(box.SelectionEnd), + "precondition: the slot holds a selection"); + Assert.That(box.CaretIndex, Is.EqualTo(caret), "precondition: the caret is where it was put"); + } + + [AvaloniaTest] + public void AnArrowWithASelection_OnlyCollapsesIt_AndTheNextPressMoves() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + var second = Find(field, "Reversal.en.1"); + PlaceSelection(second, 0, 0, "abode".Length); + + Press(second, Key.Left); + + Assert.That(second.SelectionStart, Is.EqualTo(second.SelectionEnd), "the selection is gone"); + Assert.That(second.IsFocused, Is.True, "the first press collapses and goes no further"); + Assert.That(second.CaretIndex, Is.Zero, "Left collapses to the selection's start"); + + Press(second, Key.Left); + + Assert.That(first.IsFocused, Is.True, "the next press moves as usual"); + Assert.That(first.CaretIndex, Is.EqualTo("dwelling".Length)); + } + + [AvaloniaTest] + public void RightWithASelection_CollapsesToItsEnd() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var second = Find(field, "Reversal.en.1"); + PlaceSelection(second, 0, 0, "abode".Length); + + Press(second, Key.Right); + + Assert.That(second.IsFocused, Is.True); + Assert.That(second.CaretIndex, Is.EqualTo("abode".Length)); + Assert.That(second.SelectionStart, Is.EqualTo(second.SelectionEnd)); + } + + [AvaloniaTest] + public void ASelection_RestartsThePositionUpAndDownNavigateBy() + { + var (_, window, firstLine, secondLine) = ShowWrappingGroup(); + var start = firstLine.Last(); + PlaceCaret(start, start.Text.Length); + Press(start, Key.Down); + var target = secondLine[0]; + PlaceSelection(target, 0, 0, target.Text.Length); + + Press(target, Key.Up); + Assert.That(target.SelectionStart, Is.EqualTo(target.SelectionEnd), "the selection is gone"); + Assert.That(FocusedSlot(window), Is.SameAs(target), "the first press only collapses"); + + Press(target, Key.Up); + + Assert.That(FocusedSlot(window), Is.SameAs(firstLine[0]), + "the selection ended the run, so Up follows the caret's own position"); + } + + [AvaloniaTest] + public void ShiftArrows_StillExtendTheSelection() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var box = Find(field, "Reversal.en.0"); + PlaceSelection(box, 0, 0, 3); + + Press(box, Key.Right, KeyModifiers.Shift); + + Assert.That(box.SelectionStart, Is.Not.EqualTo(box.SelectionEnd), + "a selection gesture is not a navigation one, so it is left alone"); + Assert.That(box.IsFocused, Is.True); + } + + private static void PressKey(Window window, PhysicalKey key, + RawInputModifiers modifiers = RawInputModifiers.None) + { + window.KeyPressQwerty(key, modifiers); + Dispatcher.UIThread.RunJobs(); + } + + private static int DrawnCaret(TextBox box) + => box.GetVisualDescendants().OfType().First().CaretIndex; + + private static void AssertCaret(TextBox box, int caret, string because) + { + var state = $"{because} (caret {box.CaretIndex}, drawn {DrawnCaret(box)}, " + + $"selection {box.SelectionStart}..{box.SelectionEnd}, focused {box.IsFocused})"; + Assert.That(box.IsFocused, Is.True, state); + Assert.That(DrawnCaret(box), Is.EqualTo(caret), state); + Assert.That(box.CaretIndex, Is.EqualTo(caret), state); + Assert.That(box.SelectionStart, Is.EqualTo(box.SelectionEnd), state); + } + + // A keyboard selection draws the caret at its moving end, and the arrows that follow + // must work from there, wherever the text box keeps its own caret index meanwhile. + [AvaloniaTest] + public void AfterAKeyboardSelection_ArrowsMoveFromWhereTheCaretIsDrawn() + { + var (field, _, window) = Show(new RecordingReversalContext(), null, + English("dwelling"), French("maison")); + var word = Find(field, "Reversal.en.0"); + var below = Find(field, "Reversal.fr.0"); + PlaceCaret(word, "dwelling".Length); + + PressKey(window, PhysicalKey.ArrowLeft, RawInputModifiers.Control | RawInputModifiers.Shift); + Assert.That(word.SelectedText, Is.EqualTo("dwelling"), "precondition: the word is selected"); + + PressKey(window, PhysicalKey.ArrowDown); + AssertCaret(word, 0, "Down collapses the selection where the caret is drawn, and stays"); + + PressKey(window, PhysicalKey.ArrowDown); + AssertCaret(below, 0, "the line below is entered under the caret, not under the word's end"); + + PressKey(window, PhysicalKey.ArrowUp); + AssertCaret(word, 0, "Up comes back to the same place"); + + PressKey(window, PhysicalKey.ArrowRight); + AssertCaret(word, 1, "Right moves within the word, since the caret is at its start"); + + PressKey(window, PhysicalKey.ArrowLeft); + AssertCaret(word, 0, "Left moves back within the word"); + } + + [AvaloniaTest] + public void AModifiedArrow_RestartsThePositionUpAndDownNavigateBy() + { + var (_, window, firstLine, secondLine) = ShowWrappingGroup(); + var start = firstLine.Last(); + PlaceCaret(start, start.Text.Length); + Press(start, Key.Down); + PlaceCaret(secondLine[0], 0); + + Press(secondLine[0], Key.Up, KeyModifiers.Shift); + Press(secondLine[0], Key.Up); + + Assert.That(FocusedSlot(window), Is.SameAs(firstLine[0]), + "the modified arrow ended the run, so Up follows the caret's own position"); + } + + [AvaloniaTest] + public void UpAtTheTopLine_AndDownAtTheBottom_StayPut() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling")); + var first = Find(field, "Reversal.en.0"); + var add = Find(field, "Reversal.en.Add"); + + PlaceCaret(first, 2); + Press(first, Key.Up); + Assert.That(first.IsFocused, Is.True); + + PlaceCaret(add, 0); + Press(add, Key.Down); + Assert.That(add.IsFocused, Is.True); + } + + [AvaloniaTest] + public void CtrlHomeAndCtrlEnd_GoToTheEndsOfTheWholeField() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, + English("dwelling", "abode"), French("maison")); + var first = Find(field, "Reversal.en.0"); + var last = Find(field, "Reversal.fr.Add"); + var middle = Find(field, "Reversal.fr.0"); + + PlaceCaret(middle, 1); + Press(middle, Key.Home, KeyModifiers.Control); + Assert.That(first.IsFocused, Is.True, "Ctrl+Home goes to the field's own first slot"); + Assert.That(first.CaretIndex, Is.Zero); + + PlaceCaret(first, 2); + Press(first, Key.End, KeyModifiers.Control); + Assert.That(last.IsFocused, Is.True, "Ctrl+End goes to the field's own last slot"); + Assert.That(last.CaretIndex, Is.EqualTo(last.Text.Length)); + } + + [AvaloniaTest] + public void CtrlArrows_AtASlotsEdge_MoveBetweenSlotsToo() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + var second = Find(field, "Reversal.en.1"); + + PlaceCaret(second, 0); + Press(second, Key.Left, KeyModifiers.Control); + Assert.That(first.IsFocused, Is.True); + Assert.That(first.CaretIndex, Is.EqualTo("dwelling".Length)); + + PlaceCaret(first, "dwelling".Length); + Press(first, Key.Right, KeyModifiers.Control); + Assert.That(second.IsFocused, Is.True); + Assert.That(second.CaretIndex, Is.Zero); + } + + [AvaloniaTest] + public void ArrowsInsideASlot_StayInIt() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling", "abode")); + var second = Find(field, "Reversal.en.1"); + + PlaceCaret(second, 2); + Press(second, Key.Left); + Assert.That(second.IsFocused, Is.True, "the caret is not at the start"); + + PlaceCaret(second, 0); + Press(second, Key.Left, KeyModifiers.Shift); + Assert.That(second.IsFocused, Is.True, "a selecting arrow keeps its text behavior"); + + PlaceCaret(second, 2); + Press(second, Key.Left, KeyModifiers.Control); + Assert.That(second.IsFocused, Is.True, "Ctrl+Left inside the text moves within the slot"); + } + + [AvaloniaTest] + public void ArrowsAtTheFieldsEnds_GoNowhere() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling")); + var first = Find(field, "Reversal.en.0"); + var add = Find(field, "Reversal.en.Add"); + + PlaceCaret(first, 0); + Press(first, Key.Left); + Assert.That(first.IsFocused, Is.True); + + PlaceCaret(add, 0); + Press(add, Key.Right); + Assert.That(add.IsFocused, Is.True); + } + + [AvaloniaTest] + public void RightAtAGroupsLastSlot_MovesIntoTheNextGroup() + { + var (field, _, _) = Show(new RecordingReversalContext(), null, English("dwelling"), French("maison")); + var add = Find(field, "Reversal.en.Add"); + PlaceCaret(add, 0); + + Press(add, Key.Right); + + Assert.That(Find(field, "Reversal.fr.0").IsFocused, Is.True); + } + + [AvaloniaTest] + public void InARightToLeftGroup_TheArrowsMirror() + { + var arabic = new DetailReversalGroup("ar", "Ara", null, true, new[] + { + new DetailReversalRow("ar0", "بيت", false), + new DetailReversalRow("ar1", "دار", false), + new DetailReversalRow("ar-add", "", true) + }); + var (field, _, _) = Show(new RecordingReversalContext(), null, arabic); + var first = Find(field, "Reversal.ar.0"); + var second = Find(field, "Reversal.ar.1"); + + PlaceCaret(second, 0); + Press(second, Key.Right); + Assert.That(first.IsFocused, Is.True, "Right at the start moves back, since the start is on the right"); + + PlaceCaret(first, first.Text.Length); + Press(first, Key.Left); + Assert.That(second.IsFocused, Is.True, "Left at the end moves on"); + } + + [AvaloniaTest] + public void MovingBetweenSlotsByArrow_SavesNothing() + { + var context = new RecordingReversalContext(); + var (field, _, _) = Show(context, null, English("dwelling", "abode")); + var first = Find(field, "Reversal.en.0"); + first.Focus(); + first.Text = "house"; + PlaceCaret(first, first.Text.Length); + + Press(first, Key.Right); + + Assert.That(context.Events, Is.Empty); + } + + [AvaloniaTest] + public void AnUntouchedRow_NeverCommits() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling")); + + TypeAndLeave(Find(field, "Reversal.en.0"), "dwelling", other); + TypeAndLeave(Find(field, "Reversal.en.Add"), string.Empty, other); + + Assert.That(context.Events, Is.Empty); + } + + [AvaloniaTest] + public void ClearingARow_CommitsEmptyText() + { + var context = new RecordingReversalContext(); + var (field, other, _) = Show(context, null, English("dwelling")); + + TypeAndLeave(Find(field, "Reversal.en.0"), string.Empty, other); + + Assert.That(context.Events, Is.EqualTo(new[] { "commit en0=" })); + } + + [AvaloniaTest] + public void CtrlClick_CommitsPendingTextThenJumps() + { + var context = new RecordingReversalContext(); + var jumps = new List(); + var (field, _, window) = Show(context, jumps, English("dwelling")); + var add = Find(field, "Reversal.en.Add"); + + add.Focus(); + add.Text = "home"; + CtrlClick(window, add); + + Assert.That(context.Events, Is.EqualTo(new[] { "commit en-add=home" }), + "the typed entry exists before it is shown"); + Assert.That(jumps, Is.EqualTo(new[] { "en-add" })); + } + + [AvaloniaTest] + public void CtrlClick_OnAnEmptyAddRow_DoesNothing() + { + var jumps = new List(); + var (field, _, window) = Show(new RecordingReversalContext(), jumps, English("dwelling")); + + CtrlClick(window, Find(field, "Reversal.en.Add")); + + Assert.That(jumps, Is.Empty); + } + + [AvaloniaTest] + public void TheRowMenu_OffersTheJump() + { + var jumps = new List(); + var (field, _, _) = Show(new RecordingReversalContext(), jumps, English("dwelling")); + var menu = Find(field, "Reversal.en.0").ContextFlyout as MenuFlyout; + + Assert.That(menu, Is.Not.Null); + var item = menu.Items.OfType().Single(); + Assert.That(item.Header, Is.EqualTo(FwAvaloniaStrings.ReversalShowInReversalIndex)); + item.RaiseEvent(new RoutedEventArgs(MenuItem.ClickEvent)); + + Assert.That(jumps, Is.EqualTo(new[] { "en0" })); + } + + [AvaloniaTest] + public void WithoutTheReversalCapability_RowsAreReadOnly_AndOfferNoJump() + { + var jumps = new List(); + var (field, _, _) = Show(new FakeDetailEditContext(), jumps, English("dwelling")); + var box = Find(field, "Reversal.en.0"); + + Assert.That(box.IsReadOnly, Is.True); + var jumpItems = (box.ContextFlyout as MenuFlyout)?.Items.OfType() + .Where(i => Equals(i.Header, FwAvaloniaStrings.ReversalShowInReversalIndex)); + Assert.That(jumpItems ?? Enumerable.Empty(), Is.Empty, + "the text box keeps its own menu, but without the jump"); + } + + [AvaloniaTest] + public void ARightToLeftGroup_FlowsRightToLeft() + { + var arabic = new DetailReversalGroup("ar", "Ara", null, true, + new[] { new DetailReversalRow("ar0", "بيت", false), new DetailReversalRow("ar-add", "", true) }); + + var (field, _, _) = Show(new RecordingReversalContext(), null, arabic); + + Assert.That(Find(field, "Reversal.ar.0").FlowDirection, Is.EqualTo(FlowDirection.RightToLeft)); + Assert.That(Find(field, "Reversal.ar.Add").FlowDirection, Is.EqualTo(FlowDirection.RightToLeft)); + } + + [AvaloniaTest] + public void Dispose_DetachesEveryHandler() + { + var (field, _, _) = Show(new RecordingReversalContext(), new List(), English("dwelling")); + var box = Find(field, "Reversal.en.0"); + var jumpMenu = box.ContextFlyout; + Assert.That(field.AttachedHandlerCount, Is.GreaterThan(0)); + + field.Dispose(); + + Assert.That(field.AttachedHandlerCount, Is.Zero); + Assert.That(box.ContextFlyout, Is.Not.SameAs(jumpMenu), "the row menu is released"); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs index 6e23705486..33e8c5aff5 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailCustomFieldRenderingTests.cs @@ -8,6 +8,7 @@ using Avalonia.Automation; using Avalonia.Controls; using Avalonia.Headless.NUnit; +using Avalonia.Interactivity; using Avalonia.Threading; using Avalonia.VisualTree; using NUnit.Framework; @@ -27,7 +28,7 @@ namespace FwAvaloniaTests [TestFixture] public class DetailCustomFieldRenderingTests { - private static DetailModel Model(Func factory) + private static DetailModel Model(Func factory) => new DetailModel("LexEntry", "Normal", new List { @@ -38,9 +39,9 @@ private static DetailModel Model(Func factory) }, new List()); - private static DataTree Show(DetailModel model) + private static DataTree Show(DetailModel model, Action linkRequested = null) { - var view = new DataTree(model); + var view = new DataTree(model, linkRequested: linkRequested); var window = new Window { Content = view, Width = 420, Height = 200 }; window.Show(); Dispatcher.UIThread.RunJobs(); @@ -57,7 +58,7 @@ public void CustomField_RendersTheFactoryControl_InTheValueColumn() var pluginControl = new TextBlock { Text = "plugin notes bar" }; AutomationProperties.SetAutomationId(pluginControl, "PluginNotesBar"); - var view = Show(Model(() => pluginControl)); + var view = Show(Model(_ => pluginControl)); var rendered = view.GetVisualDescendants().OfType() .FirstOrDefault(t => AutomationProperties.GetAutomationId(t) == "PluginNotesBar"); @@ -79,7 +80,7 @@ public void CustomField_RendersTheFactoryControl_InTheValueColumn() [AvaloniaTest] public void CustomField_WithThrowingFactory_FallsBackToTheUnsupportedRow() { - var view = Show(Model(() => throw new InvalidOperationException("plugin exploded"))); + var view = Show(Model(_ => throw new InvalidOperationException("plugin exploded"))); Assert.That(FindUnsupportedBlock(view), Is.Not.Null, "a throwing factory degrades to the explicit unsupported row"); @@ -97,10 +98,100 @@ public void CustomField_WithoutAFactory_FallsBackToTheUnsupportedRow() [AvaloniaTest] public void CustomField_WithNullReturningFactory_FallsBackToTheUnsupportedRow() { - var view = Show(Model(() => null)); + var view = Show(Model(_ => null)); Assert.That(FindUnsupportedBlock(view), Is.Not.Null, "a null-returning factory degrades to the explicit unsupported row"); } + + [AvaloniaTest] + public void CustomField_FactoryReceivesTheViewsLinkCallback() + { + var requests = new List(); + Action received = null; + var model = Model(render => + { + received = render.LinkRequested; + return new TextBlock { Text = "plugin" }; + }); + Show(model, requests.Add); + + Assert.That(received, Is.Not.Null, "the plugin can reach the host's jump"); + received(new DetailLinkRequest(null, new DetailChooserLink("Show", "someTool"))); + Assert.That(requests, Has.Count.EqualTo(1), "the callback is the one the view was given"); + } + + // A collapse rebuilds the rows with new editors, so the ones it removes are disposed, and + // expanding again builds fresh ones rather than reviving them. + [AvaloniaTest] + public void CollapsingASection_DisposesTheEditorsItRemoves() + { + var built = new List(); + var model = new DetailModel("LexEntry", "Normal", new List + { + new DetailField("h", "Sense 1", null, null, DetailFieldKind.Header, + EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, + isEditable: false, indent: 0, isCollapsible: true, isInitiallyExpanded: true), + new DetailField("c", "Messages", "Self", null, DetailFieldKind.Custom, + EditorClassification.Dynamic, null, null, HostRouting.Product, null, null, null, + isEditable: true, indent: 1, controlFactory: _ => + { + var spy = new DisposalSpy(); + built.Add(spy); + return spy; + }) + }, + new List()); + var view = Show(model); + var header = view.GetVisualDescendants().OfType public Action WritingSystemFocused { get; } + + /// + /// The services the view hands every row it renders, such as the jump callback chooser + /// links use and the width of the writing-system abbreviation column. Null when the + /// control is built outside a rendering view. + /// + public SliceFactoryContext Render { get; } + + /// + /// The writing-system ids the row is restricted to, in order. Null or empty means no + /// restriction, which is also the case while the user has asked to see all of them. + /// + public IReadOnlyList VisibleWritingSystems { get; } } /// @@ -144,10 +162,8 @@ private static SlicePluginRegistry CreateDefault() return registry; } - // The builtin plugin list. The Reversal Entries slice - // (ReversalIndexEntrySlice) composes as a native Avalonia editable multi-WS text field through - // the plugin route. Every OTHER custom slice not absorbed by a composer route resolves to the - // labeled Unsupported worklist row. + // The builtin plugin list: the Reversal Entries slice (ReversalIndexEntrySlice). + // Every other custom slice not absorbed by a composer route renders Unsupported. internal static void RegisterBuiltins(SlicePluginRegistry registry) { registry.Register(new ReversalIndexEntryPlugin()); diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index 5d471d48e2..ca96a67e6b 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -1116,86 +1116,6 @@ public void RetagWritingSystem_RoundTripsKtptWs_OverTheSpan_AndCommits() Is.EqualTo(Cache.DefaultAnalWs), "the untouched tail keeps its original writing system"); } - // A sense with a reversal entry composes an EDITABLE reversal - // row through the ReversalIndexEntryPlugin -- not the lone Unsupported row. - [Test] - public void Compose_SenseWithReversalEntry_ComposesEditableReversalRow_NotUnsupported() - { - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => - { - var revIndex = Cache.ServiceLocator.GetInstance() - .FindOrCreateIndexForWs(Cache.DefaultAnalWs); - var riEntry = revIndex.FindOrCreateReversalEntry("dwelling"); - riEntry.SensesRS.Add(m_entry.SensesOS[0]); - }); - - var composed = DetailComposer.Compose(m_entry, Cache); - - // The reversal slice composes a Custom (plugin) row, never an Unsupported row. - var reversalRow = composed.Model.Fields - .FirstOrDefault(f => f.Kind == DetailFieldKind.Custom - && (f.Field == "ReferringReversalIndexEntries" - || (f.Label != null && f.Label.IndexOf("Reversal", System.StringComparison.OrdinalIgnoreCase) >= 0))); - Assert.That(reversalRow, Is.Not.Null, "the reversal slice composes as a plugin (Custom) row"); - Assert.That(reversalRow.ControlFactory, Is.Not.Null, "the row carries the plugin control factory"); - - // Building the control yields an editable FwMultiWsTextField (not the unsupported rendering). - var control = reversalRow.ControlFactory(); - Assert.That(control, - Is.InstanceOf(), - "the reversal plugin builds the editable multi-WS reversal-forms field"); - } - - // Editing a reversal form stages/commits through the edit - // context -- the reversal entry's ReversalForm is updated, on the same fenced session as - // the detail view. - [Test] - public void ReversalPlugin_EditingAForm_StagesAndCommitsThroughTheEditContext() - { - IReversalIndexEntry riEntry = null; - NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => - { - var revIndex = Cache.ServiceLocator.GetInstance() - .FindOrCreateIndexForWs(Cache.DefaultAnalWs); - riEntry = revIndex.FindOrCreateReversalEntry("dwelling"); - riEntry.SensesRS.Add(m_entry.SensesOS[0]); - }); - - var composed = DetailComposer.Compose(m_entry, Cache); - var sense = m_entry.SensesOS[0]; - var plugin = new ReversalIndexEntryPlugin(); - // Reuse the composer's resolved node so the plugin gets real metadata; resolve via the plugin - // directly with a build context closing over the composed edit context. - var node = composed.Model.Fields.First(f => f.Kind == DetailFieldKind.Custom - && f.ObjectHvo == sense.Hvo); - var buildContext = new SlicePluginBuildContext(sense, null, () => composed.EditContext, Cache); - var reversalControl = (SIL.FieldWorks.Common.FwAvalonia.Detail.FwMultiWsTextField) - plugin.BuildControl(buildContext); - Assert.That(reversalControl, Is.Not.Null); - - // Stage an edit through the reversal context exposed by building the field's own context. We - // drive the edit through the composed edit context indirectly: the field staged via the - // plugin's ReversalDetailEditContext. Address it directly to assert the data path. - var field = new SIL.FieldWorks.Common.FwAvalonia.Detail.DetailField( - "reversal/" + sense.Hvo, "Reversal Entries", "ReferringReversalIndexEntries", null, - SIL.FieldWorks.Common.FwAvalonia.Detail.DetailFieldKind.Text, - SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.EditorClassification.Known, - "ReversalEntriesEditor", null, - SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.HostRouting.Product, null, null, null); - var analTag = Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id; - var entryByWsKey = new Dictionary { [analTag] = riEntry }; - var reversalContext = new ReversalDetailEditContext(Cache, composed.EditContext, entryByWsKey); - - Assert.That(reversalContext.TrySetText(field, analTag, "abode"), Is.True, - "editing an existing reversal form stages through the reversal edit context"); - reversalContext.Commit(); - - Assert.That(riEntry.ReversalForm.get_String(Cache.DefaultAnalWs).Text, Is.EqualTo("abode"), - "the committed edit updated the reversal entry's form"); - Assert.That(Cache.ActionHandlerAccessor.CanUndo(), Is.True, - "the reversal edit lands on the shared global undo stack"); - } - // DATA-SAFETY: ToTsString of an UNEDITED value reproduces the original // TsString exactly via the lossless RichXml fast-path. [Test] diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/ReversalEntriesComposeTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/ReversalEntriesComposeTests.cs new file mode 100644 index 0000000000..83aba8cb19 --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/ReversalEntriesComposeTests.cs @@ -0,0 +1,866 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Avalonia.LogicalTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.LCModel; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Core.WritingSystems; +using SIL.LCModel.DomainServices; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The sense's Reversal Entries row (LT-22673) against real LCModel data: how the row + /// composes, what each row commit does to the reversal entries, and where a row's jump + /// lands. + /// + [TestFixture] + public class ReversalEntriesComposeTests : MemoryOnlyBackendProviderTestBase + { + private const string ReversalField = "ReferringReversalIndexEntries"; + private ILexEntry m_entry; + private ILexSense m_sense; + private IReversalIndex m_enIndex; + + public override void TestSetup() + { + base.TestSetup(); + // Real controls need the headless platform and its theme resources. + FwAvaloniaRuntime.EnsureInitialized(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + // The project outlives each test, so start every test from empty reversal + // indexes. + var indexes = Cache.LanguageProject.LexDbOA.ReversalIndexesOC; + foreach (var index in indexes.ToList()) + indexes.Remove(index); + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(m_sense); + m_enIndex = Cache.ServiceLocator.GetInstance() + .FindOrCreateIndexForWs(Cache.DefaultAnalWs); + }); + } + + private int EnWs => Cache.DefaultAnalWs; + + private string EnTag => Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Id; + + private int UndoCount => Cache.ActionHandlerAccessor.UndoableSequenceCount; + + private IReversalIndexEntry AddEntry(IReversalIndex index, string form, params ILexSense[] senses) + { + IReversalIndexEntry entry = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + entry = index.FindOrCreateReversalEntry(form); + foreach (var sense in senses) + entry.SensesRS.Add(sense); + }); + return entry; + } + + private IReversalIndexEntry AddSubentry(IReversalIndexEntry parent, string form, params ILexSense[] senses) + { + IReversalIndexEntry entry = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + entry = Cache.ServiceLocator.GetInstance().Create(); + parent.SubentriesOS.Add(entry); + entry.ReversalForm.set_String(EnWs, form); + foreach (var sense in senses) + entry.SensesRS.Add(sense); + }); + return entry; + } + + private ILexSense AddOtherSense() + { + ILexSense sense = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + var entry = Cache.ServiceLocator.GetInstance().Create(); + sense = Cache.ServiceLocator.GetInstance().Create(); + entry.SensesOS.Add(sense); + }); + return sense; + } + + private CoreWritingSystemDefinition AddAnalysisWs(string tag, bool rightToLeft = false) + { + CoreWritingSystemDefinition ws = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + WritingSystemServices.FindOrCreateWritingSystem(Cache, null, tag, false, false, out ws); + ws.RightToLeftScript = rightToLeft; + // The project outlives each test, so a writing system may already be current. + if (!Cache.LangProject.CurrentAnalysisWritingSystems.Contains(ws)) + Cache.LangProject.AddToCurrentAnalysisWritingSystems(ws); + }); + return ws; + } + + private IReversalIndex AddIndex(CoreWritingSystemDefinition ws) + { + IReversalIndex index = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + index = Cache.ServiceLocator.GetInstance().FindOrCreateIndexForWs(ws.Handle)); + return index; + } + + // The host is the detail view's own fenced context, so commits reach the real undo stack. + private (ReversalDetailEditContext Editing, IDetailEditContext Host) NewContext(ILexSense sense = null) + { + var host = DetailComposer.Compose(m_entry, Cache).EditContext; + return (new ReversalDetailEditContext(Cache, host, sense ?? m_sense, "Reversal Entries"), host); + } + + private static DetailReversalGroup Group(IReadOnlyList groups, string wsTag) + => groups.Single(g => g.WsTag == wsTag); + + private static DetailReversalRow AddRow(DetailReversalGroup group) => group.Rows.Single(r => r.IsAddSlot); + + private static List EntryTexts(DetailReversalGroup group) + => group.Rows.Where(r => !r.IsAddSlot).Select(r => r.Text).ToList(); + + private DetailField ReversalRow(bool showHidden = false) + => DetailComposer.Compose(m_entry, Cache, showHidden).Model.Fields + .SingleOrDefault(f => f.Kind == DetailFieldKind.Custom && f.Field == ReversalField); + + // ----- Compose ----- + + [Test] + public void SenseWithEntries_ComposesTheReversalField() + { + AddEntry(m_enIndex, "dwelling", m_sense); + + var row = ReversalRow(); + + Assert.That(row, Is.Not.Null, "a sense with an entry composes the plugin row"); + Assert.That(row.ControlFactory(new SliceFactoryContext()), Is.InstanceOf(), + "the row builds the reversal editor, not the Unsupported text"); + } + + [Test] + public void EntriesInOneIndex_ShareAGroup_ThenOneAddRow() + { + AddEntry(m_enIndex, "dwelling", m_sense); + AddEntry(m_enIndex, "abode", m_sense); + + var group = Group(NewContext().Editing.CreateGroups(null), EnTag); + + Assert.That(EntryTexts(group), Is.EquivalentTo(new[] { "dwelling", "abode" })); + Assert.That(group.Rows.Count(r => r.IsAddSlot), Is.EqualTo(1)); + Assert.That(group.Rows.Last().IsAddSlot, Is.True, "the add row comes last"); + Assert.That(group.WsAbbrev, Is.EqualTo(Cache.ServiceLocator.WritingSystems.DefaultAnalysisWritingSystem.Abbreviation)); + } + + [Test] + public void EntriesInTwoIndexes_ComposeTwoGroups_InAnalysisOrder() + { + var es = AddAnalysisWs("es"); + AddEntry(m_enIndex, "dwelling", m_sense); + AddEntry(AddIndex(es), "casa", m_sense); + + var groups = NewContext().Editing.CreateGroups(null); + + Assert.That(groups.Select(g => g.WsTag), Is.EqualTo(new[] { EnTag, es.Id })); + Assert.That(EntryTexts(Group(groups, es.Id)), Is.EqualTo(new[] { "casa" })); + } + + [Test] + public void Subentry_ShowsItsAncestorChain() + { + var top = AddEntry(m_enIndex, "top"); + var middle = AddSubentry(top, "middle"); + AddSubentry(middle, "leaf", m_sense); + + var group = Group(NewContext().Editing.CreateGroups(null), EnTag); + + Assert.That(EntryTexts(group), Is.EqualTo(new[] { "top: middle: leaf" })); + } + + [Test] + public void FormsInOtherWritingSystems_RideTheRowAsAlternatives() + { + // A variant of the index's own language, which is what an entry's alternatives use. + var enGb = AddAnalysisWs("en-GB"); + var entry = AddEntry(m_enIndex, "house", m_sense); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => entry.ReversalForm.set_String(enGb.Handle, "houze")); + + var group = Group(NewContext().Editing.CreateGroups(null), EnTag); + var row = group.Rows.Single(r => !r.IsAddSlot); + + Assert.That(row.OtherWsForms.Select(a => a.Text), Is.EqualTo(new[] { "houze" })); + Assert.That(row.OtherWsForms.Single().WsAbbrev, Is.EqualTo(enGb.Abbreviation)); + Assert.That(AddRow(group).OtherWsForms, Is.Empty, "an add row has no alternatives"); + } + + [Test] + public void AnalysisWsWithoutAnIndex_ComposesNoGroup_AndCreatesNoIndex() + { + var es = AddAnalysisWs("es"); + AddEntry(m_enIndex, "dwelling", m_sense); + var indexCount = Cache.LanguageProject.LexDbOA.ReversalIndexesOC.Count; + + var groups = NewContext().Editing.CreateGroups(null); + + Assert.That(groups.Select(g => g.WsTag), Has.None.EqualTo(es.Id)); + Assert.That(Cache.LanguageProject.LexDbOA.ReversalIndexesOC.Count, Is.EqualTo(indexCount), + "composing never creates a reversal index"); + } + + [Test] + public void NoEntries_HidesTheRow() + { + Assert.That(ReversalRow(), Is.Null, + "an ifdata row with no reversal entries composes nothing, not an Unsupported row"); + } + + [Test] + public void NoEntries_ShowHiddenFields_ComposesOnlyAddRows() + { + var row = ReversalRow(showHidden: true); + + Assert.That(row, Is.Not.Null, "Show Hidden Fields reveals the empty row"); + var groups = NewContext().Editing.CreateGroups(null); + Assert.That(groups.SelectMany(g => g.Rows).All(r => r.IsAddSlot), Is.True); + Assert.That(Group(groups, EnTag).Rows, Has.Count.EqualTo(1)); + } + + [Test] + public void EntriesOnlyInAHiddenWs_StillComposeTheRow() + { + var es = AddAnalysisWs("es"); + AddEntry(AddIndex(es), "casa", m_sense); + + Assert.That(ReversalRow(), Is.Not.Null, "an entry in any writing system is data"); + var groups = NewContext().Editing.CreateGroups(new[] { EnTag }); + Assert.That(groups.Select(g => g.WsTag), Is.EqualTo(new[] { EnTag })); + Assert.That(AddRow(Group(groups, EnTag)), Is.Not.Null, "the visible index offers its add row"); + } + + // ----- Edit -> one undo step ----- + + [Test] + public void TypingInTheAddRow_CreatesAndLinksAnEntry_AsOneUndoStep() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, host) = NewContext(); + var add = AddRow(Group(editing.CreateGroups(null), EnTag)); + var before = UndoCount; + + Assert.That(editing.TryCommitRow(add.RowKey, "home"), Is.True); + host.Commit(); + + Assert.That(UndoCount - before, Is.EqualTo(1)); + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EquivalentTo(new[] { "dwelling", "home" })); + var regrouped = Group(NewContext().Editing.CreateGroups(null), EnTag); + Assert.That(regrouped.Rows.Last().IsAddSlot, Is.True, "the re-shown group ends in a fresh add row"); + } + + [Test] + public void ColonChain_CreatesEntryAndSubentry_LinksTheDeepest() + { + var (editing, host) = NewContext(); + var add = AddRow(Group(editing.CreateGroups(null), EnTag)); + var before = UndoCount; + + editing.TryCommitRow(add.RowKey, "body: arm"); + host.Commit(); + + Assert.That(UndoCount - before, Is.EqualTo(1)); + var linked = m_sense.ReferringReversalIndexEntries.Single(); + Assert.That(linked.ReversalForm.get_String(EnWs).Text, Is.EqualTo("arm")); + Assert.That(linked.OwningEntry?.ReversalForm.get_String(EnWs).Text, Is.EqualTo("body")); + Assert.That(linked.OwningEntry.SensesRS, Does.Not.Contain(m_sense), "only the deepest entry is linked"); + Assert.That(EntryTexts(Group(NewContext().Editing.CreateGroups(null), EnTag)), + Is.EqualTo(new[] { "body: arm" })); + } + + [Test] + public void ColonChain_ReusesAnExistingParent() + { + var body = AddEntry(m_enIndex, "body"); + var (editing, host) = NewContext(); + var add = AddRow(Group(editing.CreateGroups(null), EnTag)); + + editing.TryCommitRow(add.RowKey, "body: arm"); + host.Commit(); + + Assert.That(m_enIndex.EntriesOC.Count(e => e.ReversalForm.get_String(EnWs).Text == "body"), Is.EqualTo(1), + "the existing parent is reused, not duplicated"); + Assert.That(m_sense.ReferringReversalIndexEntries.Single().OwningEntry, Is.SameAs(body)); + } + + [Test] + public void ColonChain_FindsTheChainUnderWhicheverHomographHasIt() + { + AddEntry(m_enIndex, "body"); + IReversalIndexEntry second = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + second = Cache.ServiceLocator.GetInstance().Create(); + m_enIndex.EntriesOC.Add(second); + second.ReversalForm.set_String(EnWs, "body"); + }); + var arm = AddSubentry(second, "arm"); + var (editing, host) = NewContext(); + + editing.TryCommitRow(AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey, "body: arm"); + host.Commit(); + + Assert.That(m_sense.ReferringReversalIndexEntries.Single(), Is.SameAs(arm), + "a full match wins over a partial one"); + } + + [Test] + public void ClearingARow_UnlinksAndDeletesAnOrphan_AsOneUndoStep() + { + var entry = AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + var before = UndoCount; + + Assert.That(editing.TryCommitRow(row.RowKey, string.Empty), Is.True); + host.Commit(); + + Assert.That(UndoCount - before, Is.EqualTo(1)); + Assert.That(m_sense.ReferringReversalIndexEntries, Is.Empty); + Assert.That(entry.IsValidObject, Is.False, "an entry with no senses and no subentries is deleted"); + } + + [Test] + public void ClearingARow_KeepsAnEntryAnotherSenseUses() + { + var other = AddOtherSense(); + var entry = AddEntry(m_enIndex, "dwelling", m_sense, other); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + editing.TryCommitRow(row.RowKey, string.Empty); + host.Commit(); + + Assert.That(entry.IsValidObject, Is.True); + Assert.That(entry.SensesRS, Does.Contain(other)); + Assert.That(entry.SensesRS, Does.Not.Contain(m_sense)); + } + + [Test] + public void ClearingARow_KeepsAnEntryWithASubentry() + { + var other = AddOtherSense(); + var entry = AddEntry(m_enIndex, "body", m_sense); + var arm = AddSubentry(entry, "arm", other); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + editing.TryCommitRow(row.RowKey, string.Empty); + host.Commit(); + + Assert.That(entry.IsValidObject, Is.True, "an entry that still has subentries stays"); + Assert.That(arm.SensesRS, Does.Contain(other)); + } + + [Test] + public void ShorteningAChain_DeletesTheAncestorsItLeavesEmpty() + { + var (editing, host) = NewContext(); + var key = AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey; + editing.TryCommitRow(key, "arm: hand: finger"); + host.Commit(); + var finger = m_sense.ReferringReversalIndexEntries.Single(); + var hand = finger.OwningEntry; + var arm = hand.OwningEntry; + + editing.TryCommitRow(key, "arm"); + host.Commit(); + + Assert.That(m_sense.ReferringReversalIndexEntries.Single(), Is.SameAs(arm)); + Assert.That(finger.IsValidObject, Is.False); + Assert.That(hand.IsValidObject, Is.False, "the parent left with no senses and no subentries goes too"); + } + + [Test] + public void ClearingAChain_DeletesEveryLevelLeftEmpty() + { + var (editing, host) = NewContext(); + var key = AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey; + editing.TryCommitRow(key, "arm: hand: finger"); + host.Commit(); + var finger = m_sense.ReferringReversalIndexEntries.Single(); + var hand = finger.OwningEntry; + var arm = hand.OwningEntry; + + editing.TryCommitRow(key, string.Empty); + host.Commit(); + + Assert.That(new[] { finger, hand, arm }.Any(e => e.IsValidObject), Is.False); + } + + [Test] + public void TheCascade_StopsAtAnAncestorStillInUse() + { + var other = AddOtherSense(); + var arm = AddEntry(m_enIndex, "arm", other); + var hand = AddSubentry(arm, "hand"); + AddSubentry(hand, "palm", other); + var finger = AddSubentry(hand, "finger", m_sense); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + editing.TryCommitRow(row.RowKey, string.Empty); + host.Commit(); + + Assert.That(finger.IsValidObject, Is.False); + Assert.That(hand.IsValidObject, Is.True, "hand still has the subentry palm"); + Assert.That(arm.IsValidObject, Is.True, "arm still has another sense"); + } + + [Test] + public void EditingASharedEntrysRow_RelinksWithoutRenaming() + { + var other = AddOtherSense(); + var shared = AddEntry(m_enIndex, "dwelling", m_sense, other); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + editing.TryCommitRow(row.RowKey, "abode"); + host.Commit(); + + Assert.That(shared.ReversalForm.get_String(EnWs).Text, Is.EqualTo("dwelling"), "the shared entry keeps its form"); + Assert.That(shared.SensesRS, Does.Contain(other)); + Assert.That(m_sense.ReferringReversalIndexEntries.Single().ReversalForm.get_String(EnWs).Text, + Is.EqualTo("abode")); + } + + [Test] + public void UnchangedText_StagesNothing() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + Assert.That(editing.TryCommitRow(row.RowKey, "dwelling"), Is.False); + Assert.That(editing.TryCommitRow(AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey, string.Empty), + Is.False, "an empty add row is not a change"); + Assert.That(host.IsOpen, Is.False, "no session opens for a no-op commit"); + } + + [Test] + public void ReeditingAJustAddedRow_EditsThatEntry() + { + var (editing, host) = NewContext(); + var add = AddRow(Group(editing.CreateGroups(null), EnTag)); + + editing.TryCommitRow(add.RowKey, "one"); + host.Commit(); + var first = m_sense.ReferringReversalIndexEntries.Single(); + editing.TryCommitRow(add.RowKey, "two"); + host.Commit(); + + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EqualTo(new[] { "two" }), "the row's second commit replaced its own entry"); + Assert.That(first.IsValidObject, Is.False, "the replaced entry was orphaned, so it is gone"); + } + + [Test] + public void EditsToSeveralSlots_InOneFieldVisit_AreOneUndoStep() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, host) = NewContext(); + var group = Group(editing.CreateGroups(null), EnTag); + var before = UndoCount; + + editing.TryCommitRow(group.Rows.First(r => !r.IsAddSlot).RowKey, "abode"); + editing.TryCommitRow(AddRow(group).RowKey, "home"); + host.Commit(); + + Assert.That(UndoCount - before, Is.EqualTo(1), "the field's edits share one undo step"); + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EquivalentTo(new[] { "abode", "home" })); + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EquivalentTo(new[] { "dwelling" }), "one Ctrl+Z undoes the whole visit"); + } + + [Test] + public void IssuedAddKeys_AddSeparateEntries_InOneUndoStep() + { + var (editing, host) = NewContext(); + var add = AddRow(Group(editing.CreateGroups(null), EnTag)); + var second = editing.IssueAddRowKey(add.RowKey); + var before = UndoCount; + + Assert.That(second, Is.Not.Null.And.Not.EqualTo(add.RowKey)); + editing.TryCommitRow(add.RowKey, "one"); + editing.TryCommitRow(second, "two"); + host.Commit(); + + Assert.That(UndoCount - before, Is.EqualTo(1)); + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EquivalentTo(new[] { "one", "two" }), "the second slot adds, it does not replace the first"); + Assert.That(editing.IssueAddRowKey("no-such-key"), Is.Null); + } + + [Test] + public void SwappingTwoRows_KeepsBothEntriesLinked() + { + var dwelling = AddEntry(m_enIndex, "dwelling", m_sense); + var abode = AddEntry(m_enIndex, "abode", m_sense); + var (editing, host) = NewContext(); + var rows = Group(editing.CreateGroups(null), EnTag).Rows.Where(r => !r.IsAddSlot).ToList(); + var first = rows.Single(r => r.Text == "dwelling"); + var second = rows.Single(r => r.Text == "abode"); + + Assert.That(editing.TryCommitRows(new[] + { + new KeyValuePair(first.RowKey, "abode"), + new KeyValuePair(second.RowKey, "dwelling") + }), Is.True); + host.Commit(); + + Assert.That(dwelling.IsValidObject && abode.IsValidObject, Is.True, + "an entry one row lets go and another takes is never deleted"); + Assert.That(m_sense.ReferringReversalIndexEntries, Is.EquivalentTo(new[] { dwelling, abode })); + } + + [Test] + public void ShiftingTextUpARow_DeletesOnlyTheEntryNoRowKeeps() + { + var one = AddEntry(m_enIndex, "one", m_sense); + var two = AddEntry(m_enIndex, "two", m_sense); + var (editing, host) = NewContext(); + var rows = Group(editing.CreateGroups(null), EnTag).Rows.Where(r => !r.IsAddSlot).ToList(); + + editing.TryCommitRows(new[] + { + new KeyValuePair(rows.Single(r => r.Text == "one").RowKey, "two"), + new KeyValuePair(rows.Single(r => r.Text == "two").RowKey, string.Empty) + }); + host.Commit(); + + Assert.That(one.IsValidObject, Is.False, "no row shows the first entry any more"); + Assert.That(m_sense.ReferringReversalIndexEntries, Is.EqualTo(new[] { two })); + } + + [Test] + public void AnUnchangedRow_KeepsItsEntrysOtherWritingSystemForms() + { + var enGb = AddAnalysisWs("en-GB"); + var entry = AddEntry(m_enIndex, "house", m_sense); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => entry.ReversalForm.set_String(enGb.Handle, "houze")); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.Single(r => !r.IsAddSlot); + + Assert.That(editing.TryCommitRow(row.RowKey, "house"), Is.False, + "the alternative is not part of the row's text, so the row is unchanged"); + Assert.That(host.IsOpen, Is.False); + Assert.That(entry.ReversalForm.get_String(enGb.Handle).Text, Is.EqualTo("houze")); + } + + [Test] + public void PrecomposedTyping_MatchesADecomposedStoredForm() + { + const string decomposed = "café"; + const string precomposed = "café"; + var entry = AddEntry(m_enIndex, decomposed, m_sense); + var (editing, host) = NewContext(); + var group = Group(editing.CreateGroups(null), EnTag); + + Assert.That(editing.TryCommitRow(group.Rows.Single(r => !r.IsAddSlot).RowKey, precomposed), + Is.False, "the same text in another normalization is no change"); + var other = AddOtherSense(); + var (otherEditing, otherHost) = NewContext(other); + otherEditing.TryCommitRow(AddRow(Group(otherEditing.CreateGroups(null), EnTag)).RowKey, precomposed); + otherHost.Commit(); + + Assert.That(m_enIndex.EntriesOC, Is.EqualTo(new[] { entry }), "the existing entry is reused"); + Assert.That(entry.SensesRS, Does.Contain(other)); + } + + [Test] + public void ACommitForADeletedSense_ChangesNothing() + { + var entry = AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, host) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.Single(r => !r.IsAddSlot); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => m_entry.SensesOS.Remove(m_sense)); + + Assert.That(editing.TryCommitRow(row.RowKey, "abode"), Is.False); + Assert.That(editing.TryResolveMainEntryGuid(row.RowKey), Is.Null); + Assert.That(host.IsOpen, Is.False); + Assert.That(entry.ReversalForm.get_String(EnWs).Text, Is.EqualTo("dwelling")); + } + + [Test] + public void Settling_SavesWhatTheFieldHolds_WhileFocusIsStillInIt() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var host = DetailComposer.Compose(m_entry, Cache).EditContext; + var holder = new DetailEditContextHolder(); + holder.Replace(host); + var field = (FwReversalEntriesField)new ReversalIndexEntryPlugin().BuildControl( + new SlicePluginBuildContext(m_sense, null, () => host, Cache)); + var slot = field.GetLogicalDescendants().OfType() + .Single(box => box.Text == "dwelling"); + slot.Text = "abode"; + + holder.Settle(); + + Assert.That(host.IsOpen, Is.False, "the settle committed the edit it flushed"); + Assert.That(m_sense.ReferringReversalIndexEntries.Single().ReversalForm.get_String(EnWs).Text, + Is.EqualTo("abode")); + } + + // A section toggle rebuilds a row's controls without building a new edit context, so the + // control a rebuild replaced must not still be asked for the text it was holding. + [Test] + public void ARebuiltRow_LeavesOnlyTheLiveControlHoldingEdits() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var host = DetailComposer.Compose(m_entry, Cache).EditContext; + var holder = new DetailEditContextHolder(); + holder.Replace(host); + var replaced = BuildReversalField(host); + var live = BuildReversalField(host); + Slot(replaced, "dwelling").Text = "replaced"; + Slot(live, "dwelling").Text = "abode"; + + holder.Settle(); + + Assert.That(m_sense.ReferringReversalIndexEntries.Select(e => e.ReversalForm.get_String(EnWs).Text), + Is.EqualTo(new[] { "abode" }), "only the control the row shows now writes its text"); + } + + // Collapsing a section disposes the row's control without building a replacement, so its + // registration goes with it -- but never one a rebuild has since put in its place. + [Test] + public void ADisposedField_LetsGoOfOnlyItsOwnRegistration() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var host = (DetailEditContextBase)DetailComposer.Compose(m_entry, Cache).EditContext; + var replaced = BuildReversalField(host); + var live = BuildReversalField(host); + + replaced.Dispose(); + Assert.That(host.PendingEditFlushCount, Is.EqualTo(1), "the live control's registration stays"); + + live.Dispose(); + Assert.That(host.PendingEditFlushCount, Is.Zero, "a disposed control leaves nothing behind"); + } + + private FwReversalEntriesField BuildReversalField(IDetailEditContext host) + => (FwReversalEntriesField)new ReversalIndexEntryPlugin().BuildControl( + new SlicePluginBuildContext(m_sense, null, () => host, Cache)); + + private static Avalonia.Controls.TextBox Slot(FwReversalEntriesField field, string text) + => field.GetLogicalDescendants().OfType() + .Single(box => box.Text == text); + + // A context on the composed host whose view cancel is counted, and an add row in an index + // that is deleted before the row writes, so any batch through it fails. + private (ReversalDetailEditContext Editing, IDetailEditContext Host, string DoomedRow, + Func ViewCancels) ContextWithADoomedRow() + { + var es = AddAnalysisWs("es"); + var esIndex = AddIndex(es); + var host = DetailComposer.Compose(m_entry, Cache).EditContext; + var cancels = 0; + var editing = new ReversalDetailEditContext(Cache, host, m_sense, "Reversal Entries", () => + { + cancels++; + host.Cancel(); + }); + var doomed = AddRow(Group(editing.CreateGroups(null), es.Id)).RowKey; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => Cache.LanguageProject.LexDbOA.ReversalIndexesOC.Remove(esIndex)); + return (editing, host, doomed, () => cancels); + } + + // Cancelling through the view re-shows it, so no other field keeps showing text the + // cancel rolled back. + [Test] + public void AFailedBatch_InAnotherFieldsSession_CancelsThroughTheView() + { + var (editing, host, doomed, viewCancels) = ContextWithADoomedRow(); + ((DetailEditContextBase)host).Stage(() => + { + m_sense.Gloss.set_String(EnWs, "seed"); + return true; + }, "Gloss"); + + Assert.That(editing.TryCommitRow(doomed, "casa"), Is.False); + + Assert.That(viewCancels(), Is.EqualTo(1), "the view cancelled, so it re-shows"); + Assert.That(host.IsOpen, Is.False); + } + + // With nothing else staged there is nothing stale to re-show, and a view cancel would + // only throw away whatever the user is still typing. + [Test] + public void AFailedBatch_WithNothingElseStaged_LeavesTheViewAlone() + { + var (editing, host, doomed, viewCancels) = ContextWithADoomedRow(); + + Assert.That(editing.TryCommitRow(doomed, "casa"), Is.False); + + Assert.That(viewCancels(), Is.Zero); + Assert.That(host.IsOpen, Is.False, "no session is left open either way"); + } + + // A failed batch closes the session it wrote into, at the cost of the edit that opened + // it, rather than carrying half a batch to the next save. + [Test] + public void AFailedBatch_ClosesTheSession_LeavingNothingToSave() + { + var es = AddAnalysisWs("es"); + var esIndex = AddIndex(es); + var (editing, host) = NewContext(); + var groups = editing.CreateGroups(null); + var enAdd = AddRow(Group(groups, EnTag)); + var esAdd = AddRow(Group(groups, es.Id)); + // The second row still points at this index, which is gone by the time it writes. + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, + () => Cache.LanguageProject.LexDbOA.ReversalIndexesOC.Remove(esIndex)); + ((DetailEditContextBase)host).Stage(() => + { + m_sense.Gloss.set_String(EnWs, "seed"); + return true; + }, "Gloss"); + Assert.That(host.IsOpen, Is.True, "precondition: another field's edit opened the session"); + + var staged = editing.TryCommitRows(new[] + { + new KeyValuePair(enAdd.RowKey, "home"), + new KeyValuePair(esAdd.RowKey, "casa") + }); + + Assert.That(staged, Is.False); + Assert.That(host.IsOpen, Is.False, "the failed batch closed the session"); + Assert.That(m_sense.ReferringReversalIndexEntries, Is.Empty, + "neither row was written, the one before the failure included"); + Assert.That(m_sense.Gloss.get_String(EnWs).Text, Is.Null, + "the edit that opened the session went with it"); + } + + [Test] + public void AnAddedEntry_PersistsIntoTheNextCompose() + { + var (editing, host) = NewContext(); + editing.TryCommitRow(AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey, "home"); + host.Commit(); + + Assert.That(ReversalRow(), Is.Not.Null, "the row now has data, so it composes"); + Assert.That(EntryTexts(Group(NewContext().Editing.CreateGroups(null), EnTag)), Is.EqualTo(new[] { "home" })); + } + + // ----- Re-show ----- + + [Test] + public void UndoAndRedo_OfAnAdd_RoundTrip() + { + var (editing, host) = NewContext(); + editing.TryCommitRow(AddRow(Group(editing.CreateGroups(null), EnTag)).RowKey, "home"); + host.Commit(); + + Cache.ActionHandlerAccessor.Undo(); + Assert.That(m_sense.ReferringReversalIndexEntries, Is.Empty); + Cache.ActionHandlerAccessor.Redo(); + Assert.That(m_sense.ReferringReversalIndexEntries.Single().ReversalForm.get_String(EnWs).Text, + Is.EqualTo("home")); + } + + // ----- Cluster / bidi ----- + + [Test] + public void RightToLeftIndex_RoundTripsItsForm() + { + var ar = AddAnalysisWs("ar", rightToLeft: true); + AddIndex(ar); + var (editing, host) = NewContext(); + var group = Group(editing.CreateGroups(null), ar.Id); + const string form = "بَيْت"; + + Assert.That(group.RightToLeft, Is.True); + editing.TryCommitRow(AddRow(group).RowKey, form); + host.Commit(); + + Assert.That(m_sense.ReferringReversalIndexEntries.Single().ReversalForm.get_String(ar.Handle).Text, + Is.EqualTo(form), "combining marks survive the colon split and the commit"); + } + + [TestCase(" body : arm ", new[] { "body", "arm" })] + [TestCase("::", new string[0])] + [TestCase("body::arm", new[] { "body", "arm" })] + [TestCase(":body:", new[] { "body" })] + [TestCase("", new string[0])] + public void SplitForms_TrimsAndDropsEmptyParts(string text, string[] expected) + { + Assert.That(ReversalDetailEditContext.SplitForms(text), Is.EqualTo(expected)); + } + + // ----- Navigation ----- + + [Test] + public void ATopLevelEntrysRow_JumpsToThatEntry() + { + var entry = AddEntry(m_enIndex, "dwelling", m_sense); + var (editing, _) = NewContext(); + var group = Group(editing.CreateGroups(null), EnTag); + + Assert.That(editing.TryResolveMainEntryGuid(group.Rows.First(r => !r.IsAddSlot).RowKey), + Is.EqualTo(entry.Guid)); + Assert.That(editing.TryResolveMainEntryGuid(AddRow(group).RowKey), Is.Null, "an add row has no entry"); + } + + [Test] + public void ASubentrysRow_JumpsToItsMainEntry() + { + var top = AddEntry(m_enIndex, "body"); + AddSubentry(top, "arm", m_sense); + var (editing, _) = NewContext(); + var row = Group(editing.CreateGroups(null), EnTag).Rows.First(r => !r.IsAddSlot); + + Assert.That(editing.TryResolveMainEntryGuid(row.RowKey), Is.EqualTo(top.Guid)); + } + + [Test] + public void TheJumpRequest_TargetsTheReversalIndexTool() + { + var entry = AddEntry(m_enIndex, "dwelling", m_sense); + var requests = new List(); + + ReversalIndexEntryPlugin.RequestShowInReversalIndex(requests.Add, null, entry.Guid); + + Assert.That(requests.Single().Link.Tool, Is.EqualTo(ReversalIndexEntryPlugin.ReversalIndexTool)); + Assert.That(requests.Single().Link.TargetGuid, Is.EqualTo(entry.Guid.ToString())); + } + + [Test] + public void BuildControl_WithoutAHostEditContext_IsReadOnly() + { + AddEntry(m_enIndex, "dwelling", m_sense); + var context = new SlicePluginBuildContext(m_sense, null, () => null, Cache); + + var field = new ReversalIndexEntryPlugin().BuildControl(context); + + Assert.That(field, Is.InstanceOf(), + "with no edit session to stage on, the rows still show"); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs index 456cc31680..07589e7a1c 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/DetailWritingSystemStateTests.cs @@ -150,10 +150,11 @@ public void PluginRowEditorFocus_UpdatesWritingSystemHvoProperty_ThroughPubSub() { window.Show(); Dispatcher.UIThread.RunJobs(); - // The plugin stamps . on each of its value boxes. + // The plugin stamps .. on each entry's box. var row = composed.Model.Fields.First(f => f.Kind == DetailFieldKind.Custom && f.ObjectHvo == sense.Hvo); - var boxId = (row.AutomationId ?? ReversalIndexEntryPlugin.DefaultAutomationId) + "." + analysis.Id; + var boxId = (row.AutomationId ?? ReversalIndexEntryPlugin.DefaultAutomationId) + "." + analysis.Id + + ".0"; var reversalBox = view.GetVisualDescendants().OfType() .FirstOrDefault(box => AutomationProperties.GetAutomationId(box) == boxId); Assert.That(reversalBox, Is.Not.Null, diff --git a/Src/xWorks/xWorksTests/Avalonia/Plugins/LexemeEditorInventoryTests.cs b/Src/xWorks/xWorksTests/Avalonia/Plugins/LexemeEditorInventoryTests.cs index d8477ccf00..f786600c07 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Plugins/LexemeEditorInventoryTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Plugins/LexemeEditorInventoryTests.cs @@ -229,6 +229,7 @@ private sealed class FakeMessagesPlugin : ISlicePlugin public SIL.FieldWorks.Common.FwAvalonia.ViewDefinition.ViewNode LastNode; public IDetailEditContext LastEditContext; public LcmCache LastCache; + public SliceFactoryContext LastRender; public string LegacyClassName => MessageSliceClassName; @@ -239,6 +240,7 @@ public Avalonia.Controls.Control BuildControl(SlicePluginBuildContext context) LastNode = context.Node; LastEditContext = context.EditContext; LastCache = context.Cache; + LastRender = context.Render; return null; // never rendered in this fixture; the view's null guard covers this } } @@ -292,7 +294,7 @@ public void PluginRowFactory_ClosesOverObjectNodeCacheAndTheComposedEditContext( var composed = DetailComposer.Compose(m_entry, Cache, plugins: registry); var row = composed.Model.Fields.Single(f => f.Kind == DetailFieldKind.Custom); - row.ControlFactory(); + row.ControlFactory(null); Assert.That(plugin.BuildCalls, Is.EqualTo(1)); Assert.That(plugin.LastObject?.Hvo, Is.EqualTo(m_entry.Hvo)); @@ -301,5 +303,23 @@ public void PluginRowFactory_ClosesOverObjectNodeCacheAndTheComposedEditContext( Assert.That(plugin.LastEditContext, Is.SameAs(composed.EditContext), "the deferred accessor resolves to the detail view's own composed edit context"); } + + [Test] + public void PluginRowFactory_PassesTheRenderContextThrough() + { + var registry = new SlicePluginRegistry(); + var plugin = new FakeMessagesPlugin(); + registry.Register(plugin); + var composed = DetailComposer.Compose(m_entry, Cache, plugins: registry); + var row = composed.Model.Fields.Single(f => f.Kind == DetailFieldKind.Custom); + Action linkRequested = request => { }; + var render = new SliceFactoryContext(linkRequested: linkRequested, + wsAbbrevColumnWidth: 37); + + row.ControlFactory(render); + + Assert.That(plugin.LastRender, Is.SameAs(render), + "the plugin reaches the host's jump and column width through the render context"); + } } }