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
257 changes: 165 additions & 92 deletions Src/Common/FwAvalonia/Detail/DataTree.cs

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions Src/Common/FwAvalonia/Detail/DetailModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1682,6 +1682,19 @@ public DetailField(
/// <summary>Initial expansion state of a collapsible header (from the layout's expansion attr).</summary>
public bool IsInitiallyExpanded { get; }

private string _expansionKey;

/// <summary>
/// The key a collapsible header's expansion state is recorded and persisted under;
/// <see cref="StableId"/> when none is set. Settable so a row can carry a key that names
/// its object by guid, which still identifies that object after the project is reopened.
/// </summary>
public string ExpansionKey
{
get => _expansionKey ?? StableId;
set => _expansionKey = value;
}

/// <summary>Legacy slice menu id (layout `menu=`) for right-click on the row/label (13.x).</summary>
public string MenuId { get; }

Expand Down
179 changes: 179 additions & 0 deletions Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
// 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;
using Avalonia;
using Avalonia.Automation;
using Avalonia.Automation.Peers;
using Avalonia.Automation.Provider;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Layout;
using Avalonia.Media;

namespace SIL.FieldWorks.Common.FwAvalonia.Detail
{
/// <summary>
/// A collapsible section header: a flat button showing a +/- box before the section label.
/// The box shows minus while the section is expanded and plus while it is collapsed. A
/// header with no rows under it cannot toggle; it keeps the box's space but draws no box,
/// so its label lines up with its siblings'. The owner applies the toggle on Click, which
/// the ExpandCollapse automation pattern raises too, and then sets
/// <see cref="IsExpanded"/>.
/// </summary>
public sealed class DetailSectionHeader : Button
{
private DetailSectionHeaderAutomationPeer _peer;
private bool _isExpanded;

/// <summary>Creates a header showing <paramref name="label"/>.</summary>
public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
{
CanToggle = canToggle;
_isExpanded = isExpanded;
Box = new DetailExpanderBox(isExpanded) { Opacity = canToggle ? 1 : 0 };
Background = FwAvaloniaDensity.TransparentBrush;
BorderThickness = new Thickness(0);
Padding = new Thickness(0);
// A section header hugs the left edge.
HorizontalAlignment = HorizontalAlignment.Left;
HorizontalContentAlignment = HorizontalAlignment.Left;
var text = CreateLabel(label);
text.VerticalAlignment = VerticalAlignment.Center;
Content = new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = FwAvaloniaDensity.TreeBoxGap,
Children = { Box, text }
};
}

/// <summary>
/// The section header label text, in the section header size, colour and bold weight,
/// for a header that cannot collapse as well as inside this one.
/// </summary>
public static TextBlock CreateLabel(string label) => new TextBlock
{
Text = label ?? string.Empty,
FontSize = FwAvaloniaDensity.SectionHeaderFontSize,
Foreground = FwAvaloniaDensity.SectionHeaderBrush,
FontWeight = FontWeight.Bold
};

/// <summary>Themed as a plain <see cref="Button"/>.</summary>
protected override Type StyleKeyOverride => typeof(Button);

/// <summary>False when the header has no rows to show or hide.</summary>
public bool CanToggle { get; }

/// <summary>
/// Whether the rows under the header are shown. Setting it redraws the +/- box and tells
/// assistive technology the state changed; it does not show or hide any rows.
/// </summary>
public bool IsExpanded
{
get => _isExpanded;
set
{
if (_isExpanded == value)
return;
var old = ExpandCollapseState;
_isExpanded = value;
Box.IsExpanded = value;
_peer?.RaiseExpandCollapseStateChanged(old, ExpandCollapseState);
}
}

/// <summary>The +/- box.</summary>
public DetailExpanderBox Box { get; }

private ExpandCollapseState ExpandCollapseState
=> !CanToggle ? ExpandCollapseState.LeafNode
: _isExpanded ? ExpandCollapseState.Expanded
: ExpandCollapseState.Collapsed;

/// <inheritdoc />
protected override AutomationPeer OnCreateAutomationPeer()
=> _peer = new DetailSectionHeaderAutomationPeer(this);

private void RequestToggle() => OnClick();

private sealed class DetailSectionHeaderAutomationPeer : ButtonAutomationPeer, IExpandCollapseProvider
{
public DetailSectionHeaderAutomationPeer(DetailSectionHeader owner)
: base(owner)
{
}

private DetailSectionHeader Header => (DetailSectionHeader)Owner;

public ExpandCollapseState ExpandCollapseState => Header.ExpandCollapseState;

public bool ShowsMenu => false;

public void Expand()
{
if (Header.ExpandCollapseState == ExpandCollapseState.Collapsed)
Header.RequestToggle();
}

public void Collapse()
{
if (Header.ExpandCollapseState == ExpandCollapseState.Expanded)
Header.RequestToggle();
}

public void RaiseExpandCollapseStateChanged(ExpandCollapseState old, ExpandCollapseState current)
=> RaisePropertyChangedEvent(ExpandCollapsePatternIdentifiers.ExpandCollapseStateProperty,
old, current);
}
}

/// <summary>
/// The square +/- box of a <see cref="DetailSectionHeader"/>: a thin outline holding a
/// minus bar, plus a vertical bar while collapsed.
/// </summary>
public sealed class DetailExpanderBox : Border
{
private readonly Rectangle _verticalBar;

/// <summary>Creates a box drawing a minus when <paramref name="isExpanded"/>, else a
/// plus.</summary>
public DetailExpanderBox(bool isExpanded)
{
Width = FwAvaloniaDensity.TreeBoxSize;
Height = FwAvaloniaDensity.TreeBoxSize;
BorderThickness = FwAvaloniaDensity.HairlineBorderThickness;
BorderBrush = FwAvaloniaDensity.TreeBoxBorderBrush;
Background = FwAvaloniaDensity.TreeBoxBackgroundBrush;
VerticalAlignment = VerticalAlignment.Center;
_verticalBar = Bar(FwAvaloniaDensity.TreeBoxGlyphThickness, FwAvaloniaDensity.TreeBoxGlyphLength);
_verticalBar.IsVisible = !isExpanded;
Child = new Panel
{
Children =
{
Bar(FwAvaloniaDensity.TreeBoxGlyphLength, FwAvaloniaDensity.TreeBoxGlyphThickness),
_verticalBar
}
};
}

/// <summary>True when the box draws a minus, false when it draws a plus.</summary>
public bool IsExpanded
{
get => !_verticalBar.IsVisible;
set => _verticalBar.IsVisible = !value;
}

private static Rectangle Bar(double width, double height) => new Rectangle
{
Width = width,
Height = height,
Fill = FwAvaloniaDensity.TreeBoxGlyphBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
}
}
72 changes: 60 additions & 12 deletions Src/Common/FwAvalonia/Detail/DetailVisibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

using System;
using System.Collections.Generic;
using System.Linq;

namespace SIL.FieldWorks.Common.FwAvalonia.Detail
{
Expand All @@ -29,11 +30,10 @@ public DetailHeaderRange(int headerIndex, int start, int end)
}

/// <summary>
/// Pure, model-level computation of collapsible-header ownership and field visibility. A
/// virtualizing items panel only realizes on-screen containers, so (unlike the legacy
/// WinForms tree boxes, and unlike DataTree's current captured-control approach) visibility
/// cannot live on control references and must be derivable from the field list and expansion
/// state alone.
/// Pure, model-level computation of collapsible-header ownership and field visibility,
/// derived from the field list and expansion state alone. Needing no controls, it answers
/// for rows that have none, such as rows a collapsed header hides or an items panel has not
/// realized.
/// </summary>
public static class DetailVisibility
{
Expand Down Expand Up @@ -74,10 +74,9 @@ public static IReadOnlyList<DetailHeaderRange> GetCollapsibleRanges(IReadOnlyLis
/// visibility depends only on its ancestors' state, never its own.
/// </summary>
/// <param name="getExpansionState">
/// Returns the recorded expansion state for a header's <see
/// cref="DetailField.StableId"/>,
/// or null when none is recorded (falls back to <see
/// cref="DetailField.IsInitiallyExpanded"/>).
/// Returns the recorded expansion state for a header's
/// <see cref="DetailField.ExpansionKey"/>, or null when none is recorded (falls back to
/// <see cref="DetailField.IsInitiallyExpanded"/>).
/// May itself be null, meaning no state is ever recorded.
/// </param>
public static IReadOnlyList<bool> ComputeVisibility(
Expand All @@ -93,16 +92,65 @@ public static IReadOnlyList<bool> ComputeVisibility(

foreach (var range in GetCollapsibleRanges(fields))
{
var header = fields[range.HeaderIndex];
var expanded = getExpansionState?.Invoke(header.StableId) ?? header.IsInitiallyExpanded;
if (expanded)
if (IsExpanded(fields[range.HeaderIndex], getExpansionState))
continue;
for (var i = range.Start; i < range.End; i++)
visible[i] = false;
}
return visible;
}

/// <summary>
/// The collapsed headers in <paramref name="current"/> that hide a row belonging to an
/// object no row of <paramref name="previous"/> belonged to, in field order. Expanding
/// them shows an object that was just added to the record. A row hidden by any header
/// <paramref name="mustStayCollapsed"/> accepts contributes none of its headers, since
/// that row stays hidden either way. Expansion state is read as in
/// <see cref="ComputeVisibility"/>; a null <paramref name="mustStayCollapsed"/> keeps
/// no header collapsed.
/// </summary>
public static IReadOnlyList<DetailField> GetHeadersHidingNewObjects(
IReadOnlyList<DetailField> previous,
IReadOnlyList<DetailField> current,
Func<string, bool?> getExpansionState,
Func<DetailField, bool> mustStayCollapsed = null)
{
if (previous == null || current == null)
return Array.Empty<DetailField>();

var known = new HashSet<int>();
foreach (var field in previous)
known.Add(field.ObjectHvo);

var collapsed = new List<DetailHeaderRange>();
foreach (var range in GetCollapsibleRanges(current))
{
if (!IsExpanded(current[range.HeaderIndex], getExpansionState))
collapsed.Add(range);
}

var revealed = new SortedSet<int>();
for (var i = 0; i < current.Count; i++)
{
var hvo = current[i].ObjectHvo;
if (hvo == 0 || known.Contains(hvo))
continue;
var row = i;
var hiding = collapsed.Where(range => range.Start <= row && row < range.End).ToList();
if (mustStayCollapsed != null
&& hiding.Any(range => mustStayCollapsed(current[range.HeaderIndex])))
{
continue;
}
foreach (var range in hiding)
revealed.Add(range.HeaderIndex);
}
return revealed.Select(index => current[index]).ToList();
}

private static bool IsExpanded(DetailField header, Func<string, bool?> getExpansionState)
=> getExpansionState?.Invoke(header.ExpansionKey) ?? header.IsInitiallyExpanded;

/// <summary>The subsequence of <paramref name="fields"/> that <see
/// cref="ComputeVisibility"/> keeps visible.</summary>
public static IReadOnlyList<DetailField> GetVisibleFields(
Expand Down
18 changes: 17 additions & 1 deletion Src/Common/FwAvalonia/DetailHostControl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public sealed class DetailHostControl : AvaloniaHostControlBase
// also survives across SESSIONS, mirroring legacy slice-splitter persistence.
private double? _rememberedLabelColumnWidth;

// The root object of the detail last shown, or 0 when none was given.
private int _shownRootHvo;

/// <inheritdoc />
public override bool IsDetailInteractionInFlight
=> CurrentContent is DataTree tree
Expand All @@ -34,6 +37,14 @@ public DetailHostControl()
AccessibleDescription = FwAvaloniaStrings.AvaloniaHostName;
}

/// <summary>
/// Replaces the shown detail view with one for <paramref name="detail"/>, carrying focus
/// and the label column width across. When <paramref name="rootHvo"/> names the same
/// object as the previous show, any collapsed section hiding an object that was not in
/// the previous view is expanded, so an object inserted into a collapsed section is
/// shown, except a section the user collapsed after the previous view's own edit. A
/// <paramref name="rootHvo"/> of 0 never matches.
/// </summary>
public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null,
Action<string> writingSystemFocused = null,
Func<string, bool?> getExpansionState = null,
Expand All @@ -42,7 +53,8 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null
Action<DetailLinkRequest> linkRequested = null,
IFwClipboard clipboard = null,
Func<double?> getLabelColumnWidth = null,
Action<double> labelColumnWidthChanged = null)
Action<double> labelColumnWidthChanged = null,
int rootHvo = 0)
{
if (detail == null) throw new ArgumentNullException(nameof(detail));
// Splitter position persists per-HOST across re-shows: this long-lived host owns
Expand All @@ -62,6 +74,10 @@ public void ShowDetail(DetailModel detail, IDetailEditContext editContext = null
view.EditCompleted += (s, e) => RaiseDetailEditCompleted();
view.InteractionCompleted += (s, e) => RaiseDetailInteractionCompleted();

if (rootHvo != 0 && rootHvo == _shownRootHvo && CurrentContent is DataTree previous)
view.RevealObjectsAddedSince(previous);
_shownRootHvo = rootHvo;

// Continuity across the re-show, applied once the new view lays out. Focus leaves the
// old view first: detaching a focused editor makes Avalonia refocus and scroll away.
var focusMemento = DetailFocusMemory.Capture(CurrentContent);
Expand Down
Loading
Loading