From b47999163ef5f4689218887bca0d90c194c0969c Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:51:27 -0400 Subject: [PATCH 1/2] LT-22691: Reach reference-vector items from the keyboard Until now a mouse click was the only way to make a reference-vector item current in the Avalonia detail view: Tab skipped the items and landed on the row's hover-hidden "+" launcher, so Backspace/Delete, the context-menu key and Move Left/Right all needed the mouse first. The WinForms VectorReferenceView is incomplete in its own way (tabbing in selects nothing, and its bare arrows reorder on reorderable rows while they navigate on possibility rows), so the target is better than it, not parity. A row's items now cost exactly one Tab stop, always the first item: the WinForms launcher drops its selection whenever focus leaves the row, so a remembered position has no precedent, and one rule with no exceptions is the easier one to predict. The "+" launcher and configure gear stay their own stops after the items. From a focused item, Left/Right step between items, Home/End jump to the ends, and Ctrl+Left/Right move the item on a row that can reorder, through the same edit-context move and immediate commit the item menu uses. The arrows and the chord follow the visual side on a mirrored row. Enter does nothing on an item, by decision. Co-Authored-By: Claude Fable 5.1 --- .../FwAvalonia/Detail/FwFieldControls.cs | 43 ++- .../Detail/ReferenceVectorKeyboardTests.cs | 319 ++++++++++++++++++ .../FwAvaloniaTests/DetailMenuTests.cs | 16 +- 3 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 Src/Common/FwAvalonia/FwAvaloniaTests/Detail/ReferenceVectorKeyboardTests.cs diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 92b74cbfd7..0827307430 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -1469,10 +1469,11 @@ public FwReferenceVectorField( } AutomationProperties.SetAutomationId(text, ItemAutomationId(automationId, item.Key)); // Any button selects, so a right-click's menu acts on the item under the pointer; - // focus selects too. Items are focusable (a click focuses one) but not tab stops. + // focus selects too. The first item is a chip row's one Tab stop; the editors of + // a retypable row are each a stop. var itemIndex = index; text.Focusable = true; - KeyboardNavigation.SetIsTabStop(text, retypable); + KeyboardNavigation.SetIsTabStop(text, retypable || index == 0); EventHandler focusSelect = (s, e) => SelectItem(itemIndex); text.GotFocus += focusSelect; EventHandler select = (s, e) => @@ -1570,6 +1571,44 @@ public FwReferenceVectorField( AddSeparatorBar(); } + // Keyboard reach of the items: Left/Right step between them, Home/End jump to the + // ends, Ctrl+Left/Right move the current item when the row can reorder. + var canReorder = editable && field.CanReorderItems; + EventHandler itemKeys = (s, e) => + { + // Only a focused item answers; the launcher and gear keep their own keys. + var index = _itemBlocks.FindIndex(block => ReferenceEquals(block, e.Source)); + if (index < 0) + return; + var arrow = e.Key == Key.Left || e.Key == Key.Right; + // The physical key resolved along the vector, so a mirrored row still moves + // toward the side the key names. + var forward = (e.Key == Key.Right) != (FlowDirection == FlowDirection.RightToLeft); + if (arrow && canReorder && e.KeyModifiers == KeyModifiers.Control) + { + // Only a successful stage completes the gesture (commit + host re-show). + if (editContext.TryMoveReferenceItem(field, _items[index].Key, forward)) + gestureCompleted?.Invoke(); + e.Handled = true; + return; + } + if (e.KeyModifiers != KeyModifiers.None) + return; + int target; + if (arrow) + target = forward ? index + 1 : index - 1; + else if (e.Key == Key.Home) + target = 0; + else if (e.Key == Key.End) + target = _itemBlocks.Count - 1; + else + return; + FocusItemAt(target); + e.Handled = true; + }; + AddHandler(KeyDownEvent, itemKeys); + _teardown.Add(() => RemoveHandler(KeyDownEvent, itemKeys)); + if (!editable) { // Read-only rows still get the hover-reveal affordances for their separator bars. diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/ReferenceVectorKeyboardTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/ReferenceVectorKeyboardTests.cs new file mode 100644 index 0000000000..debf3e8ed0 --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/ReferenceVectorKeyboardTests.cs @@ -0,0 +1,319 @@ +// 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.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.Media; +using Avalonia.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; + +namespace FwAvaloniaTests.Detail +{ + /// + /// Headless proof of the keyboard reach of reference-vector items: a row's + /// items cost one Tab stop (always the first item), Left/Right and Home/End move + /// between them, and Ctrl+Left/Right move the current item through the edit context. + /// Every test drives real key presses through the headless input pipeline, never the + /// handlers directly. + /// + [TestFixture] + public class ReferenceVectorKeyboardTests + { + private static DetailField TextField(string id) + => new DetailField(id, id, id, null, DetailFieldKind.Text, EditorClassification.Known, + id, null, HostRouting.Inherit, + new List { new DetailWsValue("vern", "value") }, null, null, + objectHvo: 1234); + + private static DetailField VectorField(string id, bool editable, bool canReorder, + params string[] itemKeys) + => new DetailField(id, id, id, null, DetailFieldKind.ReferenceVector, + EditorClassification.Known, id, null, HostRouting.Inherit, null, null, null, + isEditable: editable, menuId: "mnuReorderVector", objectHvo: 1234, + items: itemKeys.Select(k => new DetailChoiceOption(k, k.ToUpperInvariant())).ToList(), + canReorderItems: canReorder); + + private static (Window Window, DataTree View, FakeDetailEditContext Context) Show( + params DetailField[] fields) + { + var context = new FakeDetailEditContext(); + var model = new DetailModel("LexEntry", "Normal", fields.ToList(), + new List()); + var view = new DataTree(model, editContext: context, menuRequested: request => { }); + var window = new Window { Content = view, Width = 480, Height = 300 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (window, view, context); + } + + private static T Find(Visual root, string automationId) where T : Visual + => root.GetVisualDescendants().OfType() + .First(c => AutomationProperties.GetAutomationId(c) == automationId); + + private static string FocusedAutomationId(Visual root) + { + var focused = root.GetVisualDescendants().OfType() + .FirstOrDefault(c => c.IsFocused); + return focused == null ? null : AutomationProperties.GetAutomationId(focused); + } + + private static void Press(Window window, PhysicalKey key, + RawInputModifiers modifiers = RawInputModifiers.None) + { + window.KeyPressQwerty(key, modifiers); + Dispatcher.UIThread.RunJobs(); + } + + private static void FocusControl(Visual root, string automationId) + { + Find(root, automationId).Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(FocusedAutomationId(root), Is.EqualTo(automationId), "precondition"); + } + + private static List TabThrough(Window window, Visual root, int stops, + RawInputModifiers modifiers = RawInputModifiers.None) + { + var visited = new List(); + for (var i = 0; i < stops; i++) + { + Press(window, PhysicalKey.Tab, modifiers); + visited.Add(FocusedAutomationId(root)); + } + return visited; + } + + [AvaloniaTest] + public void Tab_EntersAnEditableVectorRow_AtItsFirstItem_ThenItsLauncher_ThenTheNextRow() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c"), + TextField("Row2")); + FocusControl(view, "Row0.vern"); + + Assert.That(TabThrough(window, view, 3), + Is.EqualTo(new[] { "Subentries.Item.a", "Subentries.Add", "Row2.vern" }), + "the items cost one Tab stop, then the launcher, then the next row"); + } + + [AvaloniaTest] + public void ShiftTab_RetracesTheRowExactly() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c"), + TextField("Row2")); + FocusControl(view, "Row2.vern"); + + Assert.That(TabThrough(window, view, 3, RawInputModifiers.Shift), + Is.EqualTo(new[] { "Subentries.Add", "Subentries.Item.a", "Row0.vern" }), + "Shift+Tab visits the launcher, then the row's one item stop, then the row above"); + } + + [AvaloniaTest] + public void Tab_EntersAVectorRow_AtItsFirstItem_EvenWhenAnotherItemIsCurrent() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c"), + TextField("Row2")); + var vector = Find(view, "Subentries"); + vector.SelectItem("b"); + FocusControl(view, "Row0.vern"); + + Press(window, PhysicalKey.Tab); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a"), + "Tab always enters at the first item; a remembered position has no precedent"); + Assert.That(vector.SelectedItemKey, Is.EqualTo("a"), "and focus makes it current"); + + Press(window, PhysicalKey.Tab); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Add")); + + Press(window, PhysicalKey.Tab, RawInputModifiers.Shift); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a"), + "Shift+Tab from the launcher also lands on the first item"); + } + + [AvaloniaTest] + public void Tab_EntersAReadOnlyVectorRow_AtItsFirstItem_AndLeavesStraightToTheNextRow() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: false, canReorder: false, "a", "b"), + TextField("Row2")); + FocusControl(view, "Row0.vern"); + + Assert.That(TabThrough(window, view, 2), + Is.EqualTo(new[] { "Subentries.Item.a", "Row2.vern" }), + "a read-only row has no launcher, so its one item stop is followed by the next row"); + } + + [AvaloniaTest] + public void Tab_EntersAnEmptyEditableVectorRow_AtItsLauncher() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true), + TextField("Row2")); + FocusControl(view, "Row0.vern"); + + Assert.That(TabThrough(window, view, 2), + Is.EqualTo(new[] { "Subentries.Add", "Row2.vern" }), + "with no items the launcher is the row's only stop"); + } + + [AvaloniaTest] + public void LeftAndRight_StepBetweenItems_AndHoldAtTheEnds() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c"), + TextField("Row2")); + var vector = Find(view, "Subentries"); + FocusControl(view, "Subentries.Item.a"); + + Press(window, PhysicalKey.ArrowRight); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b")); + Assert.That(vector.SelectedItemKey, Is.EqualTo("b"), "the current item follows focus"); + Press(window, PhysicalKey.ArrowRight); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.c")); + Press(window, PhysicalKey.ArrowRight); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.c"), + "Right on the last item holds"); + + Press(window, PhysicalKey.ArrowLeft); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b")); + Press(window, PhysicalKey.ArrowLeft); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a")); + Press(window, PhysicalKey.ArrowLeft); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a"), + "Left on the first item holds"); + Assert.That(vector.SelectedItemKey, Is.EqualTo("a")); + + Press(window, PhysicalKey.ArrowRight); + Press(window, PhysicalKey.Tab); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Add"), + "Tab from any item goes on to the launcher"); + Press(window, PhysicalKey.Tab, RawInputModifiers.Shift); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a"), + "the arrows never move the Tab stop, so Tab back in lands on the first item"); + } + + [AvaloniaTest] + public void LeftAndRight_FollowTheVisualSide_OnAMirroredRow() + { + var (window, view, context) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c")); + var vector = Find(view, "Subentries"); + vector.FlowDirection = FlowDirection.RightToLeft; + Dispatcher.UIThread.RunJobs(); + FocusControl(view, "Subentries.Item.a"); + + Press(window, PhysicalKey.ArrowLeft); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b"), + "on a mirrored row the next item lies to the visual left"); + Press(window, PhysicalKey.ArrowRight); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a")); + + Press(window, PhysicalKey.ArrowLeft); + Press(window, PhysicalKey.ArrowRight, RawInputModifiers.Control); + Assert.That(context.ReferenceMoves, Is.EqualTo(new[] { ("Subentries", "b", false) }), + "Ctrl+Right moves the item toward the visual right, which is toward the start"); + } + + [AvaloniaTest] + public void HomeAndEnd_JumpToTheFirstAndLastItem() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c"), + TextField("Row2")); + var vector = Find(view, "Subentries"); + FocusControl(view, "Subentries.Item.b"); + + Press(window, PhysicalKey.End); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.c")); + Assert.That(vector.SelectedItemKey, Is.EqualTo("c")); + + Press(window, PhysicalKey.Home); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.a")); + Assert.That(vector.SelectedItemKey, Is.EqualTo("a")); + } + + [AvaloniaTest] + public void CtrlLeftAndRight_MoveTheCurrentItem_ThroughTheEditContext_AndCommit() + { + var (window, view, context) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c")); + FocusControl(view, "Subentries.Item.b"); + + Press(window, PhysicalKey.ArrowRight, RawInputModifiers.Control); + Assert.That(context.ReferenceMoves, Is.EqualTo(new[] { ("Subentries", "b", true) }), + "Ctrl+Right moves the current item forward (Move Right)"); + Assert.That(context.CommitCount, Is.EqualTo(1), + "the gesture commits at once, like a menu-driven move"); + + Press(window, PhysicalKey.ArrowLeft, RawInputModifiers.Control); + Assert.That(context.ReferenceMoves.Last(), Is.EqualTo(("Subentries", "b", false)), + "Ctrl+Left moves it back (Move Left)"); + Assert.That(context.CommitCount, Is.EqualTo(2)); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b"), + "the chord itself never moves focus; the host's re-show carries it to the moved item"); + } + + [AvaloniaTest] + public void CtrlLeftAndRight_DoNothing_OnARowThatCannotReorder() + { + var (window, view, context) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: false, "a", "b", "c")); + var vector = Find(view, "Subentries"); + FocusControl(view, "Subentries.Item.b"); + + Press(window, PhysicalKey.ArrowRight, RawInputModifiers.Control); + Press(window, PhysicalKey.ArrowLeft, RawInputModifiers.Control); + + Assert.That(context.ReferenceMoves, Is.Empty, "no move is even attempted"); + Assert.That(context.CommitCount, Is.EqualTo(0)); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b")); + Assert.That(vector.SelectedItemKey, Is.EqualTo("b")); + } + + [AvaloniaTest] + public void CtrlLeftAndRight_DoNothing_OnAReadOnlyRow_EvenOneThatCouldReorder() + { + var (window, view, context) = Show(TextField("Row0"), + VectorField("Subentries", editable: false, canReorder: true, "a", "b", "c")); + var vector = Find(view, "Subentries"); + FocusControl(view, "Subentries.Item.b"); + + Press(window, PhysicalKey.ArrowRight, RawInputModifiers.Control); + Press(window, PhysicalKey.ArrowLeft, RawInputModifiers.Control); + + Assert.That(context.ReferenceMoves, Is.Empty, "a read-only row never consults the edit context"); + Assert.That(context.CommitCount, Is.EqualTo(0)); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Item.b")); + Assert.That(vector.SelectedItemKey, Is.EqualTo("b")); + } + + [AvaloniaTest] + public void ArrowKeys_OnTheLauncher_LeaveTheItemsAlone() + { + var (window, view, _) = Show(TextField("Row0"), + VectorField("Subentries", editable: true, canReorder: true, "a", "b", "c")); + var vector = Find(view, "Subentries"); + FocusControl(view, "Subentries.Add"); + + Press(window, PhysicalKey.ArrowRight); + Press(window, PhysicalKey.ArrowLeft); + Press(window, PhysicalKey.Home); + + Assert.That(FocusedAutomationId(view), Is.EqualTo("Subentries.Add"), + "the row's item keys answer only from a focused item"); + Assert.That(vector.SelectedItemKey, Is.Null); + } + } +} diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs index 566051a71a..99fb94babf 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs @@ -835,17 +835,25 @@ public void FocusingAVectorItem_SelectsIt_AndTheContextMenuKey_RaisesTheItemMenu Assert.That(ReferenceEquals(requests[0].AnchorControl, second), Is.True); } - // Items take focus from a click (so the keys and the context-menu key reach them) but - // add no Tab stops to the detail view. + // Items take focus from a click (so the keys and the context-menu key reach them), and + // only the first is a Tab stop: Tab always enters a row at its first item, whatever is + // current. [AvaloniaTest] - public void VectorItems_AreFocusable_ButNotTabStops() + public void VectorItems_AreFocusable_AndOnlyTheFirstIsATabStop() { var (_, view, _) = Show(VectorField("Subentries", "a", "b")); var vector = Find(view, "Subentries"); var first = Find(view, "Subentries.Item.a"); + var second = Find(view, "Subentries.Item.b"); Assert.That(first.Focusable, Is.True); - Assert.That(KeyboardNavigation.GetIsTabStop(first), Is.False, "items are not tab stops"); + Assert.That(KeyboardNavigation.GetIsTabStop(first), Is.True, "the first item is the stop"); + Assert.That(KeyboardNavigation.GetIsTabStop(second), Is.False, "the others are not"); Assert.That(vector.Focusable, Is.False, "nor is the row"); + + vector.SelectItem("b"); + Assert.That(KeyboardNavigation.GetIsTabStop(first), Is.True, + "making another item current does not move the stop"); + Assert.That(KeyboardNavigation.GetIsTabStop(second), Is.False); } // Backspace or Delete on a focused item removes it, staging through the edit context From 8b77496ceebc4c521f3f7e2cffd9335076bc7212 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:48:23 -0400 Subject: [PATCH 2/2] LT-22691: Count the row's key handler in the read-only vector test The read-only reference-vector row now wires two teardown entries: the item's select handler and the row's item-key handler, which every row gets so keyboard navigation works on read-only rows too. The handler count test still asserted one and failed #1160's first CI run. Co-Authored-By: Claude Fable 5.1 --- .../FwAvalonia/FwAvaloniaTests/DetailEditorParityTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditorParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditorParityTests.cs index cad5a2bc51..9af8fcba42 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditorParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailEditorParityTests.cs @@ -103,11 +103,11 @@ public void ReferenceVector_Dispose_DetachesEveryHandler() [AvaloniaTest] public void ReferenceVector_ReadOnly_WiresOnlyTheItemSelectHandlers() { - // A read-only vector (no edit context) wires no edit handlers: its teardown holds - // exactly one select handler per item, and Dispose releases them. + // A read-only vector (no edit context) wires no edit handlers: one select handler + // per item plus the row's item-key handler, and Dispose releases them. var vector = new FwReferenceVectorField(VectorFieldWithItems(), "PublishIn", editContext: null); - Assert.That(vector.AttachedHandlerCount, Is.EqualTo(1), - "one item, one select handler; no Remove handler or add-picker subscription"); + Assert.That(vector.AttachedHandlerCount, Is.EqualTo(2), + "one item's select handler and the row's key handler; no Remove handler or add-picker subscription"); vector.Dispose(); Assert.That(vector.AttachedHandlerCount, Is.EqualTo(0)); }