Skip to content
1 change: 1 addition & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion Src/Common/FwAvalonia/AvaloniaHostControlBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ protected void SetHostContent(Avalonia.Controls.Control content)
}

/// <summary>The current Avalonia content, or null.</summary>
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<Control> controls)
{
Expand Down
62 changes: 59 additions & 3 deletions Src/Common/FwAvalonia/Detail/DataTree.cs
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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;
Expand Down Expand Up @@ -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) =>
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
}

/// <summary>
/// Applies <paramref name="row"/> as the TabIndex of <paramref name="root"/> and every
/// visual descendant, not just <paramref name="root"/> 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).
/// </summary>
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);
}
}

/// <summary>The detail model this view renders.</summary>
public DetailModel Model { get; }

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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);
Expand All @@ -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 };
}
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions Src/Common/FwAvalonia/Detail/FwFieldControls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GotFocusEventArgs> displayGotFocus = (s, e) =>
{
box.IsVisible = true;
display.IsVisible = false;
box.Focus();
};
display.GotFocus += displayGotFocus;
EventHandler<Avalonia.Interactivity.RoutedEventArgs> lost = (s, e) =>
{
box.IsVisible = false;
Expand All @@ -957,6 +968,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
_teardown.Add(() =>
{
display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed);
display.GotFocus -= displayGotFocus;
box.LostFocus -= lost;
});
}
Expand Down
18 changes: 17 additions & 1 deletion Src/Common/FwAvalonia/Detail/FwStructuredTextField.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GotFocusEventArgs> displayGotFocus = (s, e) =>
{
box.IsVisible = true;
display.IsVisible = false;
box.Focus();
};
display.GotFocus += displayGotFocus;
EventHandler<Avalonia.Interactivity.RoutedEventArgs> lost = (s, e) =>
{
box.IsVisible = false;
Expand All @@ -396,6 +407,7 @@ private Control CreateValueContentWithFontSwap(DetailField field, string automat
_teardown.Add(() =>
{
display.RemoveHandler(InputElement.PointerPressedEvent, displayPressed);
display.GotFocus -= displayGotFocus;
box.LostFocus -= lost;
});
}
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading