diff --git a/CONTEXT.md b/CONTEXT.md index f2aa2cc7c5..b986a7f170 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,6 +67,7 @@ It is intentionally not a full architecture manual. It should stay biased toward - **Utility**: A user-invoked data maintenance or migration tool (e.g. resetting homographs, removing parser annotations, fixing duplicate analyses). Utilities implement `IUtility` (`FwCoreDlgs`), are registered in `UtilityCatalogInclude.xml` via reflection, and run through `UtilityDlg` (Tools > Utilities menu). - **DistFiles**: Runtime assets copied into outputs or installers. - **Detail**: In the Avalonia migration, the framework-neutral model of an editing view: a flattened, ordered list of typed fields (`DetailModel` / `DetailField`) composed from the view definition and rendered by the Avalonia `DataTree`. A detail model is data; the rendered view is its visible form. It supersedes **Region**, the migration-invented word this vocabulary replaced — do not reintroduce `Region*` names for these concepts. +- **CurrentRow**: Reserved name for a future focus-tracking property/event on the Avalonia `DataTree` (`Src/Common/FwAvalonia/Detail/DataTree.cs`), analogous to the Winforms `Slice`'s `CurrentSlice`/`CurrentSliceChanged`; named for the rendered unit that would hold focus (a **row**) rather than the model vocabulary (`DetailField`) it is built from. Not currently implemented. Use this name, not `CurrentField` or another alternative, whenever a first real consumer needs it built. - **View definition**: The typed IR (`ViewDefinitionModel`, a `ViewNode` tree) compiled from the legacy XML Parts/Layout files by `ViewDefinitionCompiler` and cached by `(className, layoutName, layoutType, fingerprint)`. It is the input the composer projects into a `DetailModel`. - **Surface**: RETIRED as project vocabulary — it accumulated conflicting scopes (record UIs only, record UIs plus dialogs, any migratable UI). Say the concrete thing: a **view** (the rendered UI a tool shows for a record — the legacy WinForms `DataTree`/`BrowseViewer` or the Avalonia `DetailHostControl`), a **dialog**, or the **UI framework** (`UIFramework`, selected per tool from the `UIMode` setting via `UIFrameworkResolver` / `UIFrameworkRegistry`). "Surface" survives only in styling vocabulary (the drawn background: `FwSurfaceStyles`, the surface font) and in the linguistic term "surface form". - **Cross-namespace twin**: A deliberate reuse of a legacy type name for its Avalonia counterpart (`FwAvalonia.Detail.DataTree` beside `DetailControls.DataTree`; `SliceFactory`, `MSAGroupBox`). Disambiguate with a `using` alias at the call site; never rename the type to dodge the collision. The twins cover only the half of the legacy responsibility that still fits — the Avalonia `DataTree` renders a prebuilt `DetailModel` and does not compose it. diff --git a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs index 2f5a807c0b..3ed7a73fb1 100644 --- a/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs +++ b/Src/Common/FwAvalonia/AvaloniaHostControlBase.cs @@ -84,7 +84,8 @@ protected void SetHostContent(Avalonia.Controls.Control content) } /// The current Avalonia content, or null. - protected Avalonia.Controls.Control CurrentContent => Host.Content as Avalonia.Controls.Control; + protected Avalonia.Controls.Control CurrentContent => + Host.Content as Avalonia.Controls.Control; public void SetCompanionControls(IReadOnlyList controls) { diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index ffd956daa2..2437d1a60a 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -1,4 +1,4 @@ -// Copyright (c) 2026 SIL International +// 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) @@ -10,6 +10,7 @@ using Avalonia.Controls; using Avalonia.Layout; using Avalonia.Media; +using Avalonia.VisualTree; using Avalonia.Styling; using SIL.FieldWorks.Common.FwAvalonia; using SIL.FieldWorks.Common.FwAvalonia.Seams; @@ -165,6 +166,10 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, Width = FwAvaloniaDensity.SplitterWidth }; AutomationProperties.SetAutomationId(splitter, "DataTree.Splitter"); + // Chrome, not a field: a column-resize handle has no row of its own for a + // TabIndex, so it defaults to Avalonia's int.MaxValue and sorts out of + // order. Excluded from the tab order entirely (LT-22688). + Avalonia.Input.KeyboardNavigation.SetIsTabStop(splitter, false); Grid.SetColumn(splitter, 1); outerGrid.Children.Add(splitter); // added after the Form so its drag handle stays hit-testable outerGrid.LayoutUpdated += (s, e) => @@ -202,6 +207,11 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, AutomationProperties.SetAutomationId(scroller, "DataTree.Scroll"); Content = scroller; + // Contains Tab/Shift+Tab to this view instead of continuing into the hosting + // WinForms Form (LT-22688). + Avalonia.Input.KeyboardNavigation.SetTabNavigation(this, + Avalonia.Input.KeyboardNavigationMode.Contained); + // Screen-local command shortcuts: // Enter commits (validation-gated), Escape cancels -- handled at the view so they // work @@ -227,6 +237,10 @@ public DataTree(DetailModel model, IDetailEditContext editContext = null, // commit) before the held re-show is released. AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, (s, e) => EndPointerGesture(), Avalonia.Interactivity.RoutingStrategies.Bubble, handledEventsToo: true); + + // Scrolls newly focused rows into view (LT-22688). + AddHandler(Avalonia.Input.InputElement.GotFocusEvent, OnRowGotFocus, + Avalonia.Interactivity.RoutingStrategies.Bubble); } // A press that takes no capture, released after the pointer leaves the view, bubbles @@ -294,6 +308,30 @@ private void OnViewKeyDown(object sender, Avalonia.Input.KeyEventArgs e) } } + // Scrolls newly focused rows into view (LT-22688). + private void OnRowGotFocus(object sender, Avalonia.Input.GotFocusEventArgs e) + { + (e.Source as Control)?.BringIntoView(); + } + + /// + /// Applies as the TabIndex of and every + /// visual descendant, not just itself -- TabIndex does not + /// inherit in Avalonia, so a composite control's real focusable parts (a header's + /// hotlink button, a multi-writing-system field's per-value text box, ...) would + /// otherwise keep Avalonia's default TabIndex (int.MaxValue) and sort after every + /// explicitly row-indexed control regardless of visual position (LT-22688). + /// + private static void ApplyRowTabIndex(Control root, int row) + { + Avalonia.Input.KeyboardNavigation.SetTabIndex(root, row); + foreach (var descendant in root.GetVisualDescendants()) + { + if (descendant is Control descendantControl) + Avalonia.Input.KeyboardNavigation.SetTabIndex(descendantControl, row); + } + } + /// The detail model this view renders. public DetailModel Model { get; } @@ -490,7 +528,7 @@ private struct FieldContent public Control Label; } - private FieldContent AddField(int index, DetailField field) + private FieldContent AddField(int row, DetailField field) { var automationId = string.IsNullOrEmpty(field.AutomationId) ? field.StableId : field.AutomationId; var indent = new Thickness(field.Indent * 12, 0, 0, 0); @@ -530,6 +568,8 @@ private FieldContent AddField(int index, DetailField field) RebuildItems(); }; header = button; + // Chrome, not a field, same reasoning as the field-menu kebab (LT-22688). + Avalonia.Input.KeyboardNavigation.SetIsTabStop(button, false); } else { @@ -563,7 +603,7 @@ private FieldContent AddField(int index, DetailField field) // The header cell and its inline hotlink strip always travel together (the strip is part of // the header row, hidden/shown with it by the collapse logic). Control headerControl; - if (field.Indent == 0 && index > 0) + if (field.Indent == 0 && row > 0) { var withRule = new StackPanel(); withRule.Children.Add(new Border @@ -591,6 +631,9 @@ private FieldContent AddField(int index, DetailField field) if (headerKebab != null) HoverReveal.Attach(new[] { headerCell }, new[] { headerKebab }); + // The hotlink strip's "Field Options" button gets no TabIndex any other + // way, so it would default to int.MaxValue and sort dead last (LT-22688). + ApplyRowTabIndex(headerControl, row); return new FieldContent { Content = headerControl, Label = null }; } @@ -626,6 +669,11 @@ private FieldContent AddField(int index, DetailField field) ToolTip.SetTip(labelBlock, field.Label ?? field.Field); // 11.17: legacy label tooltips var editor = CreateEditor(field, automationId); editor.Margin = new Thickness(0, 0, 0, FwAvaloniaDensity.FieldSpacing); + + // Every reachable control in a row shares its TabIndex, so Avalonia visits them in + // visual order (editor first, then any affordance below) before moving to the next + // row (LT-22688). + ApplyRowTabIndex(editor, row); if (editor is FwReferenceVectorField vector) { _vectors.Add(vector); @@ -644,7 +692,13 @@ private FieldContent AddField(int index, DetailField field) if (labelKebab != null) HoverReveal.Attach(hoverSources, new[] { labelKebab }); if (editor is IHoverAffordanceProvider provider && provider.HoverAffordances.Count > 0) + { HoverReveal.Attach(hoverSources, provider.HoverAffordances); + // Sequences a row's own affordances (the chooser's configure gear, a + // reference vector's add/gear buttons) right after its editor (LT-22688). + foreach (var affordance in provider.HoverAffordances) + Avalonia.Input.KeyboardNavigation.SetTabIndex(affordance, row); + } return new FieldContent { Content = editor, Label = labelCell }; } @@ -696,6 +750,8 @@ private Control WrapWithFieldMenu(Control inner, DetailField field, string autom if (hasMenu || hasHotlinks) { var button = DetailChrome.CreateKebabButton(); + // Mouse/right-click reachable only; never its own Tab stop (LT-22688). + Avalonia.Input.KeyboardNavigation.SetIsTabStop(button, false); AutomationProperties.SetAutomationId(button, automationId + ".FieldMenu"); AutomationProperties.SetName(button, FwAvaloniaStrings.FieldOptionsMenu); ToolTip.SetTip(button, FwAvaloniaStrings.FieldOptionsMenu); diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index ae7f894921..f45808e0fc 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -948,6 +948,17 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat }; display.AddHandler(InputElement.PointerPressedEvent, displayPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); + // Makes `display` an ordinary tab stop; GotFocus swaps in the editable + // `box` the same way the pointer-press handler above does, matching a + // mouse click's entry point (LT-22688). + display.Focusable = true; + EventHandler displayGotFocus = (s, e) => + { + box.IsVisible = true; + display.IsVisible = false; + box.Focus(); + }; + display.GotFocus += displayGotFocus; EventHandler lost = (s, e) => { box.IsVisible = false; @@ -957,6 +968,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat _teardown.Add(() => { display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed); + display.GotFocus -= displayGotFocus; box.LostFocus -= lost; }); } diff --git a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs index b2014c3cee..9fa1a9f1c2 100644 --- a/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs +++ b/Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs @@ -387,6 +387,17 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat }; display.AddHandler(InputElement.PointerPressedEvent, displayPressed, Avalonia.Interactivity.RoutingStrategies.Tunnel); + // Makes `display` an ordinary tab stop; GotFocus swaps in the editable + // `box` the same way the pointer-press handler above does, matching a + // mouse click's entry point (LT-22688). + display.Focusable = true; + EventHandler displayGotFocus = (s, e) => + { + box.IsVisible = true; + display.IsVisible = false; + box.Focus(); + }; + display.GotFocus += displayGotFocus; EventHandler lost = (s, e) => { box.IsVisible = false; @@ -396,6 +407,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat _teardown.Add(() => { display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed); + display.GotFocus -= displayGotFocus; box.LostFocus -= lost; }); } @@ -537,7 +549,11 @@ private Button CreateIconButton(string automationId, string glyph, string access Background = FwAvaloniaDensity.TransparentBrush, BorderThickness = new Thickness(0), Foreground = FwAvaloniaDensity.WsAbbrevBrush, - VerticalAlignment = VerticalAlignment.Top + VerticalAlignment = VerticalAlignment.Top, + // The trigger must NOT take focus, matching every other per-row picker/style + // button in this file: Tab reaches the paragraph editor directly instead of + // stopping on Add/Delete first (LT-22688). + Focusable = false }; AutomationProperties.SetAutomationId(button, automationId); AutomationProperties.SetName(button, accessibleName); diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs new file mode 100644 index 0000000000..ef3d279fde --- /dev/null +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/DataTreeTabNavigationIntegrationTests.cs @@ -0,0 +1,320 @@ +// 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.Threading; +using Avalonia.VisualTree; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition; +using FwAvaloniaTests.VisualChecks; // DialogSnapshot -- the PNG harness + +namespace FwAvaloniaTests.Detail +{ + /// + /// Headless proof for LT-22688's DataTree Tab/Shift+Tab row navigation, one test per + /// navigation scenario: tab order, row-boundary containment, collapsed-row skipping, kebab + /// exclusion, scroll-into-view, native focus state, multi-writing-system rows, and rich + /// structured-text paragraphs. Every test drives Tab/Shift+Tab through the real headless + /// input pipeline (never by calling internal handlers directly), so a regression in + /// Avalonia's own tab-walk or in the host's key-claiming would fail these the same way a + /// live user would notice it. + /// + [TestFixture] + public class DataTreeTabNavigationIntegrationTests + { + private static DetailField Field(string id, DetailFieldKind kind = DetailFieldKind.Text, + int indent = 0, string menuId = null, bool isCollapsible = false, + bool isInitiallyExpanded = true, IReadOnlyList values = null) + => new DetailField(id, id, id, null, kind, + EditorClassification.Known, id, null, HostRouting.Inherit, + kind == DetailFieldKind.Text + ? values ?? new List { new DetailWsValue("vern", "value") } + : null, + null, null, + isEditable: kind == DetailFieldKind.Text, indent: indent, + isCollapsible: isCollapsible, isInitiallyExpanded: isInitiallyExpanded, + menuId: menuId, objectHvo: 1234); + + // A non-null menuRequested is what makes WrapWithFieldMenu render a field's kebab at + // all (a null host bridge means no menu button to test); the request content itself is + // irrelevant here. + private static (Window Window, DataTree View) Show(double width, double height, + params DetailField[] fields) + { + var model = new DetailModel("LexEntry", "Normal", fields.ToList(), new List()); + var view = new DataTree(model, menuRequested: request => { }); + var window = new Window { Content = view, Width = width, Height = height }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (window, view); + } + + // Same as Show(), but threads a real edit context through -- a structured-text row + // only wires up paragraph editing, and so the focus-swap, with one (LT-22688). + private static (Window Window, DataTree View) ShowWithEditContext(double width, double height, + IDetailEditContext editContext, params DetailField[] fields) + { + var model = new DetailModel("LexEntry", "Normal", fields.ToList(), new List()); + var view = new DataTree(model, editContext: editContext, menuRequested: request => { }); + var window = new Window { Content = view, Width = width, Height = height }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + return (window, view); + } + + private static T Find(Visual root, string automationId) where T : Visual + => root.GetVisualDescendants().OfType() + .First(c => AutomationProperties.GetAutomationId(c) == automationId); + + private static Control FindFocused(Visual root) + => root.GetVisualDescendants().OfType().FirstOrDefault(c => c.IsFocused); + + private static string FocusedAutomationId(Visual root) + { + var focused = FindFocused(root); + return focused == null ? null : AutomationProperties.GetAutomationId(focused); + } + + private static void Tab(Window window) + { + window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None); + Dispatcher.UIThread.RunJobs(); + } + + private static void ShiftTab(Window window) + { + window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.Shift); + Dispatcher.UIThread.RunJobs(); + } + + [AvaloniaTest] + public void TabOrderForward_VisitsEveryRowInModelOrder_NeverTheKebab() + { + var (window, view) = Show(480, 300, + Field("Row0"), + Field("Row1", menuId: "mnuDataTree-Help"), + Field("Row2"), + Field("Row3")); + + Find(view, "Row0.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Row0.vern")); + + var visited = new List(); + for (var i = 0; i < 3; i++) + { + Tab(window); + visited.Add(FocusedAutomationId(view)); + } + + Assert.That(visited, Is.EqualTo(new[] { "Row1.vern", "Row2.vern", "Row3.vern" }), + "Tab visits every row once, in model order, and never the kebab"); + DialogSnapshot.Capture(window, "DataTree-TabNavigation-01-tab-order-forward"); + } + + [AvaloniaTest] + public void TabOrderReverse_MirrorsForwardExactly() + { + var (window, view) = Show(480, 300, + Field("Row0"), + Field("Row1", menuId: "mnuDataTree-Help"), + Field("Row2"), + Field("Row3")); + + Find(view, "Row3.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + Assert.That(FocusedAutomationId(view), Is.EqualTo("Row3.vern")); + + var visited = new List(); + for (var i = 0; i < 3; i++) + { + ShiftTab(window); + visited.Add(FocusedAutomationId(view)); + } + + Assert.That(visited, Is.EqualTo(new[] { "Row2.vern", "Row1.vern", "Row0.vern" }), + "Shift+Tab retraces the forward order exactly in reverse"); + DialogSnapshot.Capture(window, "DataTree-TabNavigation-02-tab-order-reverse"); + } + + [AvaloniaTest] + public void ShiftTabAtFirstRow_StaysContained() + { + var (window, view) = Show(480, 300, Field("Row0"), Field("Row1")); + + Find(view, "Row0.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + + ShiftTab(window); + + Assert.That(FocusedAutomationId(view), Is.EqualTo("Row0.vern"), + "Contained navigation keeps Shift+Tab from leaving the view at the first row"); + DialogSnapshot.Capture(window, "DataTree-TabNavigation-03-first-row-contained"); + } + + [AvaloniaTest] + public void TabAtLastRow_StaysContained() + { + var (window, view) = Show(480, 300, Field("Row0"), Field("Row1")); + + Find(view, "Row1.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + + Tab(window); + + Assert.That(FocusedAutomationId(view), Is.EqualTo("Row1.vern"), + "Contained navigation keeps Tab from leaving the view at the last row"); + DialogSnapshot.Capture(window, "DataTree-TabNavigation-04-last-row-contained"); + } + + [AvaloniaTest] + public void Tab_SkipsRowsOwnedByACollapsedHeader() + { + var (window, view) = Show(480, 300, + Field("Before"), + Field("Section", kind: DetailFieldKind.Header, isCollapsible: true, + isInitiallyExpanded: false), + Field("Inner0", indent: 1), + Field("Inner1", indent: 1), + Field("After")); + + Find(view, "Before.vern").Focus(); + Dispatcher.UIThread.RunJobs(); + + Tab(window); + + Assert.That(FocusedAutomationId(view), Is.EqualTo("After.vern"), + "a collapsed section's own header and every row it owns are unreachable by Tab"); + DialogSnapshot.Capture(window, "DataTree-TabNavigation-05-skips-collapsed-rows"); + } + + [AvaloniaTest] + public void FieldMenuKebab_IsNeverATabStop() + { + var (window, view) = Show(480, 300, Field("Row0", menuId: "mnuDataTree-Help")); + + var kebab = Find