diff --git a/Src/Common/Controls/DetailControls/Slice.cs b/Src/Common/Controls/DetailControls/Slice.cs
index 4ac0328e7e..59c98a00f8 100644
--- a/Src/Common/Controls/DetailControls/Slice.cs
+++ b/Src/Common/Controls/DetailControls/Slice.cs
@@ -2478,25 +2478,7 @@ public bool GetCanDeleteNow()
{
CheckDisposed();
- var obj = GetObjectForMenusToOperateOn();
- if (obj == null)
- return false;
-
- var owner = obj.Owner;
- if (owner == null) // We can allow unowned objects to be deleted.
- return true;
- int flid = obj.OwningFlid;
- if (!owner.IsFieldRequired(flid))
- return true;
-
- //now, if the field is required, then we do not allow this to be deleted if it is atomic
- //futureTodo: this prevents the user from the deleting something in order to create something
- //of a different class, or to paste in other object in this field.
- if (!Cache.IsVectorProperty(flid))
- return false;
-
- // still OK to delete so long as it is not the last item.
- return Cache.DomainDataByFlid.get_VecSize(owner.Hvo, flid) > 1;
+ return DetailObjectDeletion.CanDeleteNow(GetObjectForMenusToOperateOn());
}
///
diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs
index 2db4a87af6..1a5d58e84e 100644
--- a/Src/Common/FwAvalonia/Detail/DataTree.cs
+++ b/Src/Common/FwAvalonia/Detail/DataTree.cs
@@ -94,6 +94,56 @@ internal IReadOnlyList VectorRows
/// for a gesture (a menu-driven move) made while no editor had focus.
///
public void RequestVectorFocusOnRebuild() => VectorFocusRequested = true;
+
+ // Each row's focus targets by its automation id (a header row's "editor" is the
+ // header itself), for a focus requested across a rebuild.
+ private readonly Dictionary _rowFocusTargets =
+ new Dictionary(StringComparer.Ordinal);
+
+ /// The automation ids of the rows the rebuild that follows should try to
+ /// focus, in order; null when none was requested.
+ internal IReadOnlyList RowFocusRequested { get; private set; }
+
+ ///
+ /// Asks the rebuild that follows to give keyboard focus to the first of
+ /// it shows, for a gesture that removed the
+ /// focused row and may have removed its neighbours with it.
+ ///
+ public void RequestRowFocusOnRebuild(IReadOnlyList rowAutomationIds)
+ => RowFocusRequested = rowAutomationIds;
+
+ ///
+ /// Focuses the first row of that is shown and
+ /// can take focus; false when none can.
+ ///
+ internal bool FocusFirstRow(IEnumerable rowAutomationIds)
+ {
+ if (rowAutomationIds == null)
+ return false;
+ foreach (var id in rowAutomationIds)
+ {
+ if (FocusRow(id))
+ return true;
+ }
+ return false;
+ }
+
+ ///
+ /// Focuses the row with as a label gesture does,
+ /// falling back to its field-menu button when the editor is hidden (a collapsed
+ /// section); false when no such row is shown or neither can take focus.
+ ///
+ internal bool FocusRow(string rowAutomationId)
+ {
+ if (rowAutomationId == null || !_rowFocusTargets.TryGetValue(rowAutomationId, out var row))
+ return false;
+ var target = FocusTargetOf(row.Editor, row.Kebab);
+ if (target == null || !target.IsEffectivelyVisible)
+ target = row.Kebab;
+ if (target == null || !target.IsEffectivelyVisible)
+ return false;
+ return target.Focus();
+ }
private readonly IFwClipboard _clipboard;
// Computed once per view (not per field/row) from the widest WS abbreviation across the
@@ -546,6 +596,7 @@ private void ApplyVisibility()
{
_form.Items.RemoveAt(position);
_rows[i] = null;
+ _rowFocusTargets.Remove(Model.Fields[i].RowAutomationId);
removed.Add(row);
}
continue;
@@ -665,7 +716,7 @@ private struct FieldContent
private FieldContent AddField(int row, DetailField field, ShownRow shown)
{
- var automationId = string.IsNullOrEmpty(field.AutomationId) ? field.StableId : field.AutomationId;
+ var automationId = field.RowAutomationId;
var indent = new Thickness(field.Indent * 12, 0, 0, 0);
// Section/group headers from full-layout composition span the full item width
@@ -701,6 +752,7 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// "..." field-menu button (in a thin gutter to the left of the header)
// opens the section menu/hotlinks.
var headerCell = WrapWithFieldMenu(header, field, automationId, out var headerKebab);
+ _rowFocusTargets[automationId] = (header, headerKebab);
// Discoverability parity (legacy SummaryCommandControl): a section header with hotlinks
// shows its commands as an ALWAYS-VISIBLE inline command-link strip directly beneath the
@@ -797,6 +849,7 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// gutter "..." button; the editor's current item rides each request it raises.
var labelCell = WrapWithFieldMenu(labelBlock, field, automationId, out var labelKebab,
editor as IDetailItemSelection, editor);
+ _rowFocusTargets[automationId] = (editor, labelKebab);
// Hover-reveal: the WHOLE row (label cell + editor) is the hover/focus
// surface for the field-options "..." and any editor affordance (chooser
@@ -926,17 +979,21 @@ private static void TakeRowFocus(Control cell, Control editor, Control kebab)
var focused = TopLevel.GetTopLevel(cell)?.FocusManager?.GetFocusedElement();
if (IsOn(cell, focused) || IsOn(editor, focused))
return;
- Control target;
+ FocusTargetOf(editor, kebab)?.Focus();
+ }
+
+ // The control a row's gesture focuses: a vector row its button (focusing an item would
+ // make it current), else the editor or its first focusable part; null when none.
+ private static Control FocusTargetOf(Control editor, Control kebab)
+ {
+ if (editor == null)
+ return null;
if (editor is IDetailItemSelection)
- target = kebab;
- else if (editor.Focusable)
- target = editor;
- else
- {
- target = Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(editor).OfType()
- .FirstOrDefault(c => c.Focusable && c.IsEffectivelyEnabled && c.IsEffectivelyVisible);
- }
- target?.Focus();
+ return kebab;
+ if (editor.Focusable)
+ return editor;
+ return Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(editor).OfType()
+ .FirstOrDefault(c => c.Focusable && c.IsEffectivelyEnabled && c.IsEffectivelyVisible);
}
///
diff --git a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs
index e2b570ff36..45bfc7aaeb 100644
--- a/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs
+++ b/Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs
@@ -3,6 +3,7 @@
// (http://www.gnu.org/licenses/lgpl-2.1.html)
using System;
+using System.Collections.Generic;
using Avalonia.Automation;
using Avalonia.Controls;
using Avalonia;
@@ -30,7 +31,7 @@ public sealed class Memento
{
public Memento(string automationId, int caretIndex, double verticalOffset = 0,
string vectorAutomationId = null, string vectorItemKey = null, int vectorItemIndex = -1,
- bool focusVectorItem = false)
+ bool focusVectorItem = false, IReadOnlyList rowAutomationIds = null)
{
AutomationId = automationId;
CaretIndex = caretIndex;
@@ -39,8 +40,16 @@ public Memento(string automationId, int caretIndex, double verticalOffset = 0,
VectorItemKey = vectorItemKey;
VectorItemIndex = vectorItemIndex;
FocusVectorItem = focusVectorItem;
+ RowAutomationIds = rowAutomationIds;
}
+ ///
+ /// The automation ids of the rows the outgoing view asked to try focusing after
+ /// the rebuild, in order (a gesture removed the focused row and maybe its
+ /// neighbours); null when none.
+ ///
+ public IReadOnlyList RowAutomationIds { get; }
+
///
/// True when the outgoing view asked for the vector row's current item to take
/// keyboard focus after the rebuild (a gesture made from a menu, with no editor
@@ -84,10 +93,11 @@ public static Memento Capture(Control root)
var vectorKey = selectedVector?.SelectedItemKey;
var vectorIndex = selectedVector?.SelectedItemIndex ?? -1;
var focusVector = tree?.VectorFocusRequested ?? false;
+ var focusRow = tree?.RowFocusRequested;
var focusManager = TopLevel.GetTopLevel(root)?.FocusManager;
if (!(focusManager?.GetFocusedElement() is Control focused) || !root.IsVisualAncestorOf(focused))
- return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector);
+ return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow);
// The editor itself carries the stable id (e.g. "LexemeFormEditor.vern"); walk up in
// case focus landed on an inner template part.
@@ -97,11 +107,11 @@ public static Memento Capture(Control root)
if (!string.IsNullOrEmpty(id))
{
return new Memento(id, (focused as TextBox)?.CaretIndex ?? -1, verticalOffset,
- vectorId, vectorKey, vectorIndex, focusVector);
+ vectorId, vectorKey, vectorIndex, focusVector, focusRow);
}
}
- return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector);
+ return new Memento(null, -1, verticalOffset, vectorId, vectorKey, vectorIndex, focusVector, focusRow);
}
// Whether the rebuilt view should focus the vector row's current item: the outgoing view
@@ -207,9 +217,10 @@ public static bool TryRestoreScroll(Control root, Memento memento)
///
/// Restores the memento once the incoming view has laid out: the scroll offset first (an
/// offset set before layout is clamped to zero by the still-empty extent), then the
- /// vector row's current item, then focus -- the remembered editor, or else that current
- /// item when the memento asks for it, so a move or removal made from the row keeps the
- /// keyboard in the row. Safe to call before the view is attached.
+ /// vector row's current item, then focus -- the remembered editor, else that current
+ /// item when the memento asks for it (a move or removal made from the row keeps the
+ /// keyboard in the row), else the first surviving row the outgoing view asked for (a
+ /// gesture removed the focused row). Safe to call before the view is attached.
///
public static void RestoreAfterLayout(Control root, Memento memento)
{
@@ -229,8 +240,13 @@ public static void RestoreAfterLayout(Control root, Memento memento)
if (needsScroll)
TryRestoreScroll(root, memento);
var vector = RestoreVectorSelection(root, memento);
- if (!TryRestoreFocus(root, memento) && vector != null && WantsVectorFocus(memento))
- vector.FocusItemAt(vector.SelectedItemIndex);
+ if (!TryRestoreFocus(root, memento))
+ {
+ if (vector != null && WantsVectorFocus(memento))
+ vector.FocusItemAt(vector.SelectedItemIndex);
+ else if (memento.RowAutomationIds != null)
+ (root as DataTree)?.FocusFirstRow(memento.RowAutomationIds);
+ }
// A bring-into-view request queued by a focus change is applied by the next
// arrange; the remembered offset is re-asserted after it.
if (needsScroll)
diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs
index e97190635b..153f8e143c 100644
--- a/Src/Common/FwAvalonia/Detail/DetailModel.cs
+++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs
@@ -1624,6 +1624,13 @@ public DetailField(
public string GhostPrompt { get; }
public string StableId { get; }
+
+ ///
+ /// The automation id of the row built for this field: its own when the layout gives
+ /// one, else its stable id.
+ ///
+ public string RowAutomationId => string.IsNullOrEmpty(AutomationId) ? StableId : AutomationId;
+
public string Label { get; }
public string Field { get; }
public string WritingSystem { get; }
diff --git a/Src/Common/FwAvalonia/DetailHostControl.cs b/Src/Common/FwAvalonia/DetailHostControl.cs
index a150cd730d..005e2c047d 100644
--- a/Src/Common/FwAvalonia/DetailHostControl.cs
+++ b/Src/Common/FwAvalonia/DetailHostControl.cs
@@ -3,6 +3,7 @@
// (http://www.gnu.org/licenses/lgpl-2.1.html)
using System;
+using System.Collections.Generic;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.Seams;
@@ -91,5 +92,16 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null
/// gesture (a menu-driven move) made while no editor had focus.
///
public void FocusVectorItemOnNextShow() => (CurrentContent as DataTree)?.RequestVectorFocusOnRebuild();
+
+ ///
+ /// Asks the next re-show to give keyboard focus to the first of
+ /// it still shows, for a gesture that removed the
+ /// focused row.
+ ///
+ public void FocusRowOnNextShow(IReadOnlyList rowAutomationIds)
+ => (CurrentContent as DataTree)?.RequestRowFocusOnRebuild(rowAutomationIds);
+
+ /// The detail model shown now; null before the first show.
+ public DetailModel ShownDetail => (CurrentContent as DataTree)?.Model;
}
}
diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs
index 41dc5defa0..9e3d108c5d 100644
--- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs
+++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs
@@ -50,6 +50,63 @@ public IReadOnlyList GetValues(ViewNode fieldNode)
private static DataTree NewView()
=> new DataTree(DetailModelProjector.FromViewDefinition(Definition(), new Provider()));
+ // The same view with its Gloss field gone, as a re-show after deleting that field's
+ // object would build it.
+ private static DataTree NewViewWithoutGloss()
+ {
+ var definition = Definition();
+ var withoutGloss = new ViewDefinitionModel(definition.ClassName, definition.LayoutName,
+ definition.LayoutType, new List { definition.Roots[0] }, new List());
+ return new DataTree(DetailModelProjector.FromViewDefinition(withoutGloss, new Provider()));
+ }
+
+ // A gesture removed the focused row and named its neighbours, nearest first: with the
+ // remembered editor gone from the rebuilt view, the first surviving row takes focus.
+ [AvaloniaTest]
+ public void RequestedRowFocus_FocusesTheFirstSurvivingRow_WhenTheRememberedEditorIsGone()
+ {
+ var first = NewView();
+ var window = new Window { Content = first, Width = 420, Height = 200 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ FindEditor(first, "GlossEditor.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+
+ first.RequestRowFocusOnRebuild(new[] { "RowThatAlsoVanishes", "LexemeFormEditor" });
+ var memento = DetailFocusMemory.Capture(first);
+ Assert.That(memento.AutomationId, Is.EqualTo("GlossEditor.vern"), "precondition: the doomed editor had focus");
+ Assert.That(memento.RowAutomationIds, Is.EqualTo(new[] { "RowThatAlsoVanishes", "LexemeFormEditor" }));
+
+ var second = NewViewWithoutGloss();
+ DetailFocusMemory.RestoreAfterLayout(second, memento);
+ window.Content = second;
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.That(FindEditor(second, "GlossEditor.vern"), Is.Null, "precondition: the remembered editor is gone");
+ Assert.That(FindEditor(second, "LexemeFormEditor.vern").IsFocused, Is.True,
+ "the requested row's editor takes focus");
+ }
+
+ // The remembered editor wins over a requested row when both survive the re-show.
+ [AvaloniaTest]
+ public void RequestedRowFocus_YieldsToTheRememberedEditor_WhenItSurvives()
+ {
+ var first = NewView();
+ var window = new Window { Content = first, Width = 420, Height = 200 };
+ window.Show();
+ Dispatcher.UIThread.RunJobs();
+ FindEditor(first, "GlossEditor.vern").Focus();
+ Dispatcher.UIThread.RunJobs();
+ first.RequestRowFocusOnRebuild(new[] { "LexemeFormEditor" });
+
+ var second = NewView();
+ DetailFocusMemory.RestoreAfterLayout(second, DetailFocusMemory.Capture(first));
+ window.Content = second;
+ Dispatcher.UIThread.RunJobs();
+
+ Assert.That(FindEditor(second, "GlossEditor.vern").IsFocused, Is.True);
+ }
+
private static DataTree NewLongView()
{
var roots = new List();
diff --git a/Src/FdoUi/DetailRules/DetailObjectDeletion.cs b/Src/FdoUi/DetailRules/DetailObjectDeletion.cs
new file mode 100644
index 0000000000..e95ba2301e
--- /dev/null
+++ b/Src/FdoUi/DetailRules/DetailObjectDeletion.cs
@@ -0,0 +1,59 @@
+// 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 SIL.FieldWorks.Common.FwUtils;
+using SIL.LCModel;
+
+// Rules both detail views share. Nothing here may depend on WinForms, Avalonia, or a slice
+// or control type, so the rules outlive WinForms. DetailRulesBoundaryTests enforces this.
+namespace SIL.FieldWorks.Common.DetailRules
+{
+ ///
+ /// The Delete command of a detail row: whether the row's object may be deleted now, and
+ /// the menu text that says so. Execution stays with CmObjectUi.DeleteUnderlyingObject.
+ ///
+ public static class DetailObjectDeletion
+ {
+ /// The strings-table key of the text appended to a disabled Delete
+ /// item.
+ public const string CannotDeleteKey = "(cannot delete this)";
+
+ ///
+ /// Whether may be deleted: always when unowned or in a field
+ /// its owner does not require; never from a required atomic field; from a required
+ /// vector only while it is not the last item.
+ ///
+ public static bool CanDeleteNow(ICmObject target)
+ {
+ if (target == null)
+ return false;
+ var owner = target.Owner;
+ if (owner == null)
+ return true;
+ var flid = target.OwningFlid;
+ if (!owner.IsFieldRequired(flid))
+ return true;
+ if (!target.Cache.IsVectorProperty(flid))
+ return false;
+ return target.Cache.DomainDataByFlid.get_VecSize(owner.Hvo, flid) > 1;
+ }
+
+ ///
+ /// The Delete item's text: the configured label, with the cannot-delete note appended
+ /// when is false and {0} replaced by the target's
+ /// localized class name.
+ ///
+ /// The object to delete; null leaves any {0} in
+ /// place.
+ public static string DeleteLabel(string label, ICmObject target, bool canDelete)
+ {
+ var text = label ?? string.Empty;
+ if (!canDelete)
+ text += StringTable.Table.GetString(CannotDeleteKey);
+ if (text.Contains("{0}") && target != null)
+ text = string.Format(text, StringTable.Table.GetString(target.ClassName, "ClassNames"));
+ return text;
+ }
+ }
+}
diff --git a/Src/FdoUi/FdoUiCore.cs b/Src/FdoUi/FdoUiCore.cs
index 015b30fa54..8055dae156 100644
--- a/Src/FdoUi/FdoUiCore.cs
+++ b/Src/FdoUi/FdoUiCore.cs
@@ -1022,8 +1022,22 @@ public virtual bool CanDelete(out string cannotDeleteMsg)
/// Return true if deleted, false, if cancelled.
///
public bool DeleteUnderlyingObject()
+ {
+ return DeleteUnderlyingObject(ConfirmDelete);
+ }
+
+ ///
+ /// Deletes the object once says so. The clerk's current
+ /// record is instead handed to the DeleteRecord handler, confirmation included.
+ ///
+ /// Asks the user; in the
+ /// product.
+ /// True when the object was deleted here.
+ public bool DeleteUnderlyingObject(Func confirm)
{
CheckDisposed();
+ if (confirm == null)
+ throw new ArgumentNullException(nameof(confirm));
ICmObject cmo = GetCurrentCmObject();
if (cmo != null && m_obj != null && cmo.Hvo == m_obj.Hvo)
@@ -1038,24 +1052,36 @@ public bool DeleteUnderlyingObject()
var mainWindow = m_propertyTable.GetValue