Skip to content
Open
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
13 changes: 6 additions & 7 deletions Src/Common/FwAvalonia/Detail/DataTree.cs
Original file line number Diff line number Diff line change
Expand Up @@ -752,8 +752,8 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
Control header;
if (field.IsCollapsible)
{
var button = new DetailSectionHeader(headerLabel, IsExpanded(field),
_toggleableHeaders.Contains(row));
var button = new DetailSectionHeader(headerLabel, field.CollapsedLabel,
IsExpanded(field), _toggleableHeaders.Contains(row));
button.Click += (s, e) => ToggleSection(field, button);
shown.Header = button;
header = button;
Expand All @@ -766,12 +766,12 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// A null background only hit-tests the glyphs; the whole header area must
// take the right-click.
label.Background = FwAvaloniaDensity.TransparentBrush;
AutomationProperties.SetName(label, field.Label ?? string.Empty);
header = label;
}

header.Margin = new Thickness(indent.Left, 4, 0, FwAvaloniaDensity.FieldSpacing);
AutomationProperties.SetAutomationId(header, automationId);
AutomationProperties.SetName(header, field.Label ?? string.Empty);
// The header answers right-click with its slice menu; the hover
// "..." field-menu button (in a thin gutter to the left of the header)
// opens the section menu/hotlinks.
Expand All @@ -786,11 +786,10 @@ private FieldContent AddField(int row, DetailField field, ShownRow shown)
// through the existing host bridge identically.
var hotlinkStrip = CreateHotlinkStrip(field, automationId, indent);

// Top-level sections get the heavy-weight separator rule. The header cell and
// its hotlink strip travel together: the strip is part of the header row,
// hidden and shown with it.
// Top-level sections get the heavy rule unless the layout gives them normal
// weight. The hotlink strip is part of the header row, shown and hidden with it.
Control headerControl;
if (field.Indent == 0 && row > 0)
if (field.Indent == 0 && row > 0 && !field.IsNormalWeight)
{
var withRule = new StackPanel();
withRule.Children.Add(new Border
Expand Down
12 changes: 12 additions & 0 deletions Src/Common/FwAvalonia/Detail/DetailModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1702,6 +1702,18 @@ public string ExpansionKey
set => _expansionKey = value;
}

/// <summary>
/// The text a collapsible header shows while collapsed; null when it shows
/// <see cref="Label"/> in both states.
/// </summary>
public string CollapsedLabel { get; set; }

/// <summary>
/// True for a header whose layout gives it normal rather than heavy weight (a
/// NormalSummary row); it gets no heavy section rule even at the top level.
/// </summary>
public bool IsNormalWeight { get; set; }

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

Expand Down
76 changes: 63 additions & 13 deletions Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,29 +26,36 @@ public sealed class DetailSectionHeader : Button
{
private DetailSectionHeaderAutomationPeer _peer;
private bool _isExpanded;
private readonly string _label;
private readonly string _collapsedLabel;
private readonly TextBlock _text;

/// <summary>Creates a header showing <paramref name="label"/>.</summary>
public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
/// <summary>
/// Creates a header showing <paramref name="label"/> while expanded and
/// <paramref name="collapsedLabel"/> while collapsed; a null
/// <paramref name="collapsedLabel"/> shows <paramref name="label"/> in both states.
/// </summary>
public DetailSectionHeader(string label, string collapsedLabel, bool isExpanded, bool canToggle)
{
CanToggle = canToggle;
_isExpanded = isExpanded;
_label = label ?? string.Empty;
_collapsedLabel = collapsedLabel ?? _label;
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 }
};
_text = CreateLabel(CurrentLabel);
Comment thread
thejambi marked this conversation as resolved.
AutomationProperties.SetName(this, CurrentLabel);
Content = new BoxAndLabelPanel { Children = { Box, _text } };
}

/// <summary>The text shown for the current state.</summary>
public string CurrentLabel => _isExpanded ? _label : _collapsedLabel;

/// <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.
Expand All @@ -58,18 +65,59 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle)
Text = label ?? string.Empty,
FontSize = FwAvaloniaDensity.SectionHeaderFontSize,
Foreground = FwAvaloniaDensity.SectionHeaderBrush,
FontWeight = FontWeight.Bold
FontWeight = FontWeight.Bold,
// Long lines wrap to the pane width, as the WinForms summary does.
TextWrapping = TextWrapping.Wrap
};

/// <summary>
/// Lays out the +/- box and the label side by side: the label wraps in the width left
/// after the box, and the box sits centred on the label's first line.
/// </summary>
private sealed class BoxAndLabelPanel : Panel
{
private Control BoxChild => Children[0];
private TextBlock LabelChild => (TextBlock)Children[1];

protected override Size MeasureOverride(Size availableSize)
{
BoxChild.Measure(Size.Infinity);
var boxWidth = BoxChild.DesiredSize.Width + FwAvaloniaDensity.TreeBoxGap;
LabelChild.Measure(new Size(Math.Max(0, availableSize.Width - boxWidth), availableSize.Height));
return new Size(boxWidth + LabelChild.DesiredSize.Width,
Math.Max(BoxChild.DesiredSize.Height, LabelChild.DesiredSize.Height));
}

protected override Size ArrangeOverride(Size finalSize)
{
var box = BoxChild.DesiredSize;
var boxWidth = box.Width + FwAvaloniaDensity.TreeBoxGap;
var label = LabelChild.DesiredSize;
var firstLine = FirstLineHeight(LabelChild, label.Height);
var lineTop = Math.Max(0, (box.Height - firstLine) / 2);
BoxChild.Arrange(new Rect(0, Math.Max(0, (firstLine - box.Height) / 2), box.Width, box.Height));
LabelChild.Arrange(new Rect(boxWidth, lineTop, Math.Max(0, finalSize.Width - boxWidth), label.Height));
return finalSize;
}

// The height of the label's first line, or the whole label when it has no lines yet.
private static double FirstLineHeight(TextBlock label, double whole)
{
var lines = label.TextLayout.TextLines;
return lines.Count > 0 ? lines[0].Height : whole;
}
}

/// <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.
/// Whether the rows under the header are shown. Setting it redraws the +/- box, shows
/// the label for the new state, and tells assistive technology the state changed; it
/// does not show or hide any rows.
/// </summary>
public bool IsExpanded
{
Expand All @@ -81,6 +129,8 @@ public bool IsExpanded
var old = ExpandCollapseState;
_isExpanded = value;
Box.IsExpanded = value;
_text.Text = CurrentLabel;
AutomationProperties.SetName(this, CurrentLabel);
_peer?.RaiseExpandCollapseStateChanged(old, ExpandCollapseState);
}
}
Expand Down
63 changes: 63 additions & 0 deletions Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using Avalonia.VisualTree;
using FwAvaloniaTests.VisualChecks;
using NUnit.Framework;
using SIL.FieldWorks.Common.FwAvalonia;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;

Expand Down Expand Up @@ -342,6 +343,68 @@ public void SectionHeaderAndFieldOptionsText_TakeTheirOwnSizeAndColour()
"the link is not the button theme's semibold");
}

// LT-22689: a header with a collapsed line (a sense, etymology, extended note or
// subrecord) shows it while collapsed and its own label while expanded.
[AvaloniaTest]
public void CollapsibleHeader_ShowsItsCollapsedLine_OnlyWhileCollapsed()
{
var sense = Header("sense", "Sense 1", 0);
sense.CollapsedLabel = "Sense 1 - house - n";
var note = Header("note", "Extended Note", 0, expanded: false);
note.CollapsedLabel = "Extended Note - use - formal";
var view = Show(sense, Text("g1", "Gloss", 1), note, Text("d1", "Discussion", 1));
string Shown(string id) => SectionHeader(view, id).GetVisualDescendants().OfType<TextBlock>().Single().Text;

Assert.That(Shown("sense"), Is.EqualTo("Sense 1"));
Assert.That(Shown("note"), Is.EqualTo("Extended Note - use - formal"),
"a header that starts collapsed shows its collapsed line");

ClickHeader(SectionHeader(view, "sense"));
Assert.That(Shown("sense"), Is.EqualTo("Sense 1 - house - n"));
Assert.That(AutomationProperties.GetName(SectionHeader(view, "sense")), Is.EqualTo("Sense 1 - house - n"),
"assistive technology reads the line that is shown");

ClickHeader(SectionHeader(view, "sense"));
Assert.That(Shown("sense"), Is.EqualTo("Sense 1"));
}

// A collapsed sense line with a sentence-long definition wraps to the pane, as the
// WinForms summary does, with the +/- box beside its first line.
[AvaloniaTest]
public void LongHeaderLine_WrapsWithinThePane_WithTheBoxBesideItsFirstLine()
{
var longLine = "Sense 1 - " + string.Concat(Enumerable.Repeat("a long definition word ", 12)) + "- n";
var sense = Header("sense", "Sense 1", 0, expanded: false);
sense.CollapsedLabel = longLine;
var view = Show(Header("short", "Sense 2", 0), Text("g2", "Gloss", 1), sense, Text("g1", "Gloss", 1));
var header = SectionHeader(view, "sense");
var text = header.GetVisualDescendants().OfType<TextBlock>().Single();
var oneLine = SectionHeader(view, "short").GetVisualDescendants().OfType<TextBlock>().Single();

Assert.That(text.Text, Is.EqualTo(longLine), "precondition: the collapsed line is shown");
Assert.That(text.Bounds.Height, Is.GreaterThan(oneLine.Bounds.Height * 1.5), "the line wraps");
var right = text.TranslatePoint(new Point(text.Bounds.Width, 0), view).Value.X;
Assert.That(right, Is.LessThanOrEqualTo(view.Bounds.Width), "nothing runs past the pane");
var boxMiddle = header.Box.TranslatePoint(new Point(0, header.Box.Bounds.Height / 2), text).Value.Y;
Assert.That(boxMiddle, Is.LessThan(oneLine.Bounds.Height), "the box sits beside the first line");
}

// A NormalSummary header (an etymology, an extended note) at the top level draws no
// heavy section rule; a HeavySummary one (a sense) does.
[AvaloniaTest]
public void NormalWeightHeader_DrawsNoHeavyRule_AtTheTopLevel()
{
var etymology = Header("etymology", "Etymology", 0);
etymology.IsNormalWeight = true;
var view = Show(Header("sense1", "Sense 1", 0), Text("g1", "Gloss", 1),
Header("sense2", "Sense 2", 0), Text("g2", "Gloss", 1), etymology, Text("e1", "Gloss", 1));
int RulesAbove(string id) => SectionHeader(view, id).GetVisualAncestors().OfType<StackPanel>()
.SelectMany(p => p.Children.OfType<Border>()).Count(b => b.Background == FwAvaloniaDensity.SectionRuleBrush);

Assert.That(RulesAbove("sense2"), Is.EqualTo(1), "precondition: a top-level sense gets the heavy rule");
Assert.That(RulesAbove("etymology"), Is.EqualTo(0));
}

[AvaloniaTest]
public void HeaderWithNothingUnderIt_DrawsNoBox_AndIgnoresClicks()
{
Expand Down
40 changes: 21 additions & 19 deletions Src/xWorks/Avalonia/Composer/DetailComposer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ private sealed class ComposeState
private readonly ISilDataAccess _sda;
private readonly IFwMetaDataCacheManaged _mdc;
private readonly HashSet<(int hvo, string layout)> _visited = new HashSet<(int, string)>();
private readonly DetailSummaryLines _summaryLines;

public readonly List<DetailField> Fields = new List<DetailField>();
// One edit handler per composed field, keyed by StableId. A field's text/rich-text/option/
Expand Down Expand Up @@ -369,6 +370,7 @@ public ComposeState(LcmCache cache, bool showHiddenFields,
_showAllWsFields = showAllWritingSystemsFields;
_sda = cache.DomainDataByFlid;
_mdc = (IFwMetaDataCacheManaged)cache.DomainDataByFlid.MetaDataCache;
_summaryLines = new DetailSummaryLines(cache);
}

// Track which compiled model the walk is currently inside so each emitted
Expand Down Expand Up @@ -3433,22 +3435,29 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
var expanded = node.Expansion != ViewExpansion.Collapsed;
var isSenses = node.Field == "Senses";
var sectionLabel = Localize(node.Label) ?? node.Field;
var firstItem = _cache.ServiceLocator.ObjectRepository.GetObject(_sda.get_VecItem(obj.Hvo, flid, 0));

// Items with a summary line pair (senses, etymologies, extended notes) are
// sections of their own: each item's header carries its summary lines, and no
// banner sits over them.
var summarized = DetailSummaryLines.Covers(firstItem);

// A sequence sitting directly under a section header that already names it (the
// Allomorphs/Variants shape: a summary slice wrapping an <indent> with one seq)
// would otherwise emit a second banner plus a numbered header per item, above
// item rows their own layouts already label. Both are duplicates, and with no
// label authored on the part ref or the <seq> the banner falls back to the raw
// model field name, which bypasses StringTable and cannot be translated.
var flatten = !isSenses
var flatten = !summarized
&& string.IsNullOrEmpty(node.Label)
&& FollowsEnclosingHeader(depth)
&& ItemRootIsLabelled(node, obj, flid, count);

// Nested sense sequences (Senses on a sense) don't repeat the section banner; the
// numbered items carry it.
if (!flatten && !(isSenses && obj is ILexSense))
if (!flatten && !summarized)
AddHeader(node, obj, depth, sectionLabel);
// Summarized items sit at the sequence's own level; subsenses indent under their
// sense.
var itemDepth = summarized && !(isSenses && obj is ILexSense) ? depth : depth + 1;
Comment thread
thejambi marked this conversation as resolved.

for (var i = 0; i < count; i++)
{
Expand All @@ -3461,19 +3470,10 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
continue;
}

string itemLabel;
if (isSenses && item is ILexSense sense)
{
// Legacy sense numbering: 1, 2, ... and 1.1 for subsenses, with the sense's
// summary text (ShortName = gloss) in the header line. The number
// is the domain's own LexSenseOutline (the dictionary/bulk-edit outline), so
// the entry pane cannot diverge from the other views.
itemLabel = ($"{sense.LexSenseOutline.Text} {item.ShortName}").TrimEnd();
}
else
{
string itemLabel = null;
string collapsedLabel = null;
if (!summarized || !_summaryLines.TryGet(item, out itemLabel, out collapsedLabel))
itemLabel = $"{sectionLabel} {i + 1}";
}

// The item's own layout carries the slice menu (e.g. the sense's
// HeavySummary part ref binds mnuDataTree-Sense in LexSense.fwlayout) -- the
Expand All @@ -3484,15 +3484,17 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth)
AddField(new DetailField($"{StableId(node, obj)}/item{i}",
itemLabel, node.Field, null, DetailFieldKind.Header,
EditorClassification.GroupingNone, null, null, HostRouting.Inherit,
null, null, null, isEditable: false, indent: depth + 1,
null, null, null, isEditable: false, indent: itemDepth,
isCollapsible: true, isInitiallyExpanded: expanded,
menuId: itemBinding.MenuId ?? node.MenuId,
hotlinksId: itemBinding.HotlinksId ?? node.HotlinksId,
objectHvo: item.Hvo)
{
ExpansionKey = ExpansionKey(node.StableId + "/item", item)
ExpansionKey = ExpansionKey(node.StableId + "/item", item),
CollapsedLabel = collapsedLabel,
IsNormalWeight = summarized && DetailSummaryLines.IsNormalWeight(item)
});
DescendInto(node, item, depth + 1);
DescendInto(node, item, itemDepth);
}
}

Expand Down
Loading
Loading