Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 1 addition & 19 deletions Src/Common/Controls/DetailControls/Slice.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}

/// <summary>
Expand Down
79 changes: 68 additions & 11 deletions Src/Common/FwAvalonia/Detail/DataTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,56 @@ internal IReadOnlyList<FwReferenceVectorField> VectorRows
/// for a gesture (a menu-driven move) made while no editor had focus.
/// </summary>
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<string, (Control Editor, Control Kebab)> _rowFocusTargets =
new Dictionary<string, (Control Editor, Control Kebab)>(StringComparer.Ordinal);

/// <summary>The automation ids of the rows the rebuild that follows should try to
/// focus, in order; null when none was requested.</summary>
internal IReadOnlyList<string> RowFocusRequested { get; private set; }

/// <summary>
/// Asks the rebuild that follows to give keyboard focus to the first of
/// <paramref name="rowAutomationIds"/> it shows, for a gesture that removed the
/// focused row and may have removed its neighbours with it.
/// </summary>
public void RequestRowFocusOnRebuild(IReadOnlyList<string> rowAutomationIds)
=> RowFocusRequested = rowAutomationIds;

/// <summary>
/// Focuses the first row of <paramref name="rowAutomationIds"/> that is shown and
/// can take focus; false when none can.
/// </summary>
internal bool FocusFirstRow(IEnumerable<string> rowAutomationIds)
{
if (rowAutomationIds == null)
return false;
foreach (var id in rowAutomationIds)
{
if (FocusRow(id))
return true;
}
return false;
}

/// <summary>
/// Focuses the row with <paramref name="rowAutomationId"/> 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.
/// </summary>
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
Expand Down Expand Up @@ -546,6 +596,7 @@ private void ApplyVisibility()
{
_form.Items.RemoveAt(position);
_rows[i] = null;
_rowFocusTargets.Remove(Model.Fields[i].RowAutomationId);
removed.Add(row);
}
continue;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<Control>()
.FirstOrDefault(c => c.Focusable && c.IsEffectivelyEnabled && c.IsEffectivelyVisible);
}
target?.Focus();
return kebab;
if (editor.Focusable)
return editor;
return Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(editor).OfType<Control>()
.FirstOrDefault(c => c.Focusable && c.IsEffectivelyEnabled && c.IsEffectivelyVisible);
}

/// <summary>
Expand Down
34 changes: 25 additions & 9 deletions Src/Common/FwAvalonia/Detail/DetailFocusMemory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string> rowAutomationIds = null)
{
AutomationId = automationId;
CaretIndex = caretIndex;
Expand All @@ -39,8 +40,16 @@ public Memento(string automationId, int caretIndex, double verticalOffset = 0,
VectorItemKey = vectorItemKey;
VectorItemIndex = vectorItemIndex;
FocusVectorItem = focusVectorItem;
RowAutomationIds = rowAutomationIds;
}

/// <summary>
/// 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.
/// </summary>
public IReadOnlyList<string> RowAutomationIds { get; }

/// <summary>
/// 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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -207,9 +217,10 @@ public static bool TryRestoreScroll(Control root, Memento memento)
/// <summary>
/// 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.
/// </summary>
public static void RestoreAfterLayout(Control root, Memento memento)
{
Expand All @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions Src/Common/FwAvalonia/Detail/DetailModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1624,6 +1624,13 @@ public DetailField(
public string GhostPrompt { get; }

public string StableId { get; }

/// <summary>
/// The automation id of the row built for this field: its own when the layout gives
/// one, else its stable id.
/// </summary>
public string RowAutomationId => string.IsNullOrEmpty(AutomationId) ? StableId : AutomationId;

public string Label { get; }
public string Field { get; }
public string WritingSystem { get; }
Expand Down
12 changes: 12 additions & 0 deletions Src/Common/FwAvalonia/DetailHostControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -91,5 +92,16 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null
/// gesture (a menu-driven move) made while no editor had focus.
/// </summary>
public void FocusVectorItemOnNextShow() => (CurrentContent as DataTree)?.RequestVectorFocusOnRebuild();

/// <summary>
/// Asks the next re-show to give keyboard focus to the first of
/// <paramref name="rowAutomationIds"/> it still shows, for a gesture that removed the
/// focused row.
/// </summary>
public void FocusRowOnNextShow(IReadOnlyList<string> rowAutomationIds)
=> (CurrentContent as DataTree)?.RequestRowFocusOnRebuild(rowAutomationIds);

/// <summary>The detail model shown now; null before the first show.</summary>
public DetailModel ShownDetail => (CurrentContent as DataTree)?.Model;
}
}
57 changes: 57 additions & 0 deletions Src/Common/FwAvalonia/FwAvaloniaTests/DetailFocusMemoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,63 @@ public IReadOnlyList<DetailWsValue> 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<ViewNode> { definition.Roots[0] }, new List<ViewDiagnostic>());
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<ViewNode>();
Expand Down
Loading
Loading