diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index f18c135229..ac6fd04098 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -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; @@ -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. @@ -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 diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index 7443fef167..5a98a5ae81 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1702,6 +1702,18 @@ public string ExpansionKey set => _expansionKey = value; } + /// + /// The text a collapsible header shows while collapsed; null when it shows + /// in both states. + /// + public string CollapsedLabel { get; set; } + + /// + /// 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. + /// + public bool IsNormalWeight { get; set; } + /// Legacy slice menu id (layout `menu=`) for right-click on the row/label (13.x). public string MenuId { get; } diff --git a/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs b/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs index 779fe3f47b..464d3f2346 100644 --- a/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs +++ b/Src/Common/FwAvalonia/Detail/DetailSectionHeader.cs @@ -26,12 +26,21 @@ public sealed class DetailSectionHeader : Button { private DetailSectionHeaderAutomationPeer _peer; private bool _isExpanded; + private readonly string _label; + private readonly string _collapsedLabel; + private readonly TextBlock _text; - /// Creates a header showing . - public DetailSectionHeader(string label, bool isExpanded, bool canToggle) + /// + /// Creates a header showing while expanded and + /// while collapsed; a null + /// shows in both states. + /// + 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); @@ -39,16 +48,14 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle) // 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); + AutomationProperties.SetName(this, CurrentLabel); + Content = new BoxAndLabelPanel { Children = { Box, _text } }; } + /// The text shown for the current state. + public string CurrentLabel => _isExpanded ? _label : _collapsedLabel; + /// /// 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. @@ -58,9 +65,49 @@ 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 }; + /// + /// 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. + /// + 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; + } + } + /// Themed as a plain . protected override Type StyleKeyOverride => typeof(Button); @@ -68,8 +115,9 @@ public DetailSectionHeader(string label, bool isExpanded, bool canToggle) public bool CanToggle { get; } /// - /// 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. /// public bool IsExpanded { @@ -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); } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs index d6192ff1a1..b82d78caca 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailViewingParityTests.cs @@ -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; @@ -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().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().Single(); + var oneLine = SectionHeader(view, "short").GetVisualDescendants().OfType().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() + .SelectMany(p => p.Children.OfType()).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() { diff --git a/Src/xWorks/Avalonia/Composer/DetailComposer.cs b/Src/xWorks/Avalonia/Composer/DetailComposer.cs index 020999c926..f4b1736146 100644 --- a/Src/xWorks/Avalonia/Composer/DetailComposer.cs +++ b/Src/xWorks/Avalonia/Composer/DetailComposer.cs @@ -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 Fields = new List(); // One edit handler per composed field, keyed by StableId. A field's text/rich-text/option/ @@ -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 @@ -3433,6 +3435,12 @@ 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 with one seq) @@ -3440,15 +3448,16 @@ private void WalkSequence(ViewNode node, ICmObject obj, int depth) // item rows their own layouts already label. Both are duplicates, and with no // label authored on the part ref or the 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; for (var i = 0; i < count; i++) { @@ -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 @@ -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); } } diff --git a/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs new file mode 100644 index 0000000000..bee5d9f1d1 --- /dev/null +++ b/Src/xWorks/Avalonia/Composer/DetailSummaryLines.cs @@ -0,0 +1,131 @@ +// 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.Collections.Generic; +using System.Linq; +using SIL.FieldWorks.Common.FwUtils; +using SIL.LCModel; +using SIL.LCModel.Core.WritingSystems; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The header lines of the objects whose detail layout opens with a summary row carrying a + /// collapsed variant: a sense, an etymology and an extended note. Each pair follows that + /// class's Summary and SummaryCollapsed parts in the shipped configuration + /// (LexSenseParts.xml, LexEntryParts.xml), with their literal text localized through the + /// string table. + /// + internal sealed class DetailSummaryLines + { + private readonly LcmCache _cache; + + /// Creates the line builder for 's project. + public DetailSummaryLines(LcmCache cache) + { + _cache = cache; + } + + /// True when 's class has a summary line pair. + public static bool Covers(ICmObject obj) + => obj is ILexSense || obj is ILexEtymology || obj is ILexExtendedNote; + + /// + /// True when 's layout opens with a NormalSummary row rather than + /// a HeavySummary one: an etymology or an extended note, not a sense. + /// + public static bool IsNormalWeight(ICmObject obj) => obj is ILexEtymology || obj is ILexExtendedNote; + + /// + /// The header text of while expanded and while collapsed. Returns + /// false, with both null, when its class has no summary line pair. + /// + public bool TryGet(ICmObject obj, out string expanded, out string collapsed) + { + switch (obj) + { + case ILexSense sense: + expanded = Lit("Sense ") + sense.LexSenseOutline?.Text; + collapsed = expanded + Lit(" - ") + DefinitionOrGloss(sense) + Lit(" - ") + + GrammaticalInfo(sense.MorphoSyntaxAnalysisRA); + return true; + case ILexEtymology etymology: + expanded = Lit("Etymology"); + collapsed = expanded + Lit(" - ") + (AllEmpty(etymology.LanguageNotes) + ? BestAnalysis(etymology.Gloss) + : BestAnalysis(etymology.LanguageNotes) + Lit(" - ") + BestAnalysis(etymology.Gloss)); + return true; + case ILexExtendedNote note: + expanded = Lit("Extended Note"); + collapsed = expanded + Lit(" - ") + Abbreviation(note.ExtendedNoteTypeRA) + Lit(" - ") + + BestAnalysis(note.Discussion); + return true; + default: + expanded = collapsed = null; + return false; + } + } + + // The definition, or the gloss when the definition is empty in every analysis + // writing system. + private string DefinitionOrGloss(ILexSense sense) + => AllEmpty(sense.Definition) ? BestAnalysis(sense.Gloss) : BestAnalysis(sense.Definition); + + // The MorphoSyntaxAnalysis "Basic" layout of each analysis class. + private string GrammaticalInfo(IMoMorphSynAnalysis msa) + { + switch (msa) + { + case IMoStemMsa stem: + return Abbreviation(stem.PartOfSpeechRA); + case IMoUnclassifiedAffixMsa unclassified: + return Abbreviation(unclassified.PartOfSpeechRA); + case IMoDerivAffMsa derivational: + if (derivational.FromPartOfSpeechRA == null) + { + return derivational.ToPartOfSpeechRA == null + ? Lit("Unclassified affix") + : Lit("???") + Lit(" > ") + Abbreviation(derivational.ToPartOfSpeechRA); + } + return Abbreviation(derivational.FromPartOfSpeechRA) + Lit(" > ") + + (derivational.ToPartOfSpeechRA == null + ? Lit("???.") + : Abbreviation(derivational.ToPartOfSpeechRA)); + case IMoInflAffMsa inflectional: + if (inflectional.PartOfSpeechRA == null) + return Lit("Unmk. infl. afx slot"); + return Abbreviation(inflectional.PartOfSpeechRA) + ":" + + string.Join("/", inflectional.SlotsRC.Select(slot => slot.ShortName)); + default: + return string.Empty; + } + } + + private string Abbreviation(ICmPossibility possibility) + => possibility == null ? string.Empty : BestAnalysis(possibility.Abbreviation); + + private IEnumerable AnalysisWritingSystems + => _cache.LangProject.CurrentAnalysisWritingSystems; + + // The first non-empty alternative in the analysis writing systems, in project order. + private string BestAnalysis(IMultiAccessorBase value) => Best(value, AnalysisWritingSystems); + + private static string Best(IMultiAccessorBase value, IEnumerable writingSystems) + { + if (value == null) + return string.Empty; + foreach (var ws in writingSystems) + { + var text = value.get_String(ws.Handle)?.Text; + if (!string.IsNullOrEmpty(text)) + return text; + } + return string.Empty; + } + + private bool AllEmpty(IMultiAccessorBase value) => Best(value, AnalysisWritingSystems).Length == 0; + + private static string Lit(string text) => StringTable.Table.LocalizeLiteralValue(text); + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs index 09e914f471..1a2d3f3585 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailEditContextEditingTests.cs @@ -451,8 +451,10 @@ public void Compose_WalksTheFullCompiledLayout_AcrossObjects() "each sense's gloss binds its own object"); Assert.That(glossFields.All(f => f.Indent > 0), Is.True, "sense fields are indented"); - Assert.That(fields.Any(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses"), Is.True, - "the senses sequence renders a section header"); + var senseHeaderObjects = fields.Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses") + .Select(f => f.ObjectHvo).ToList(); + Assert.That(senseHeaderObjects, Is.EquivalentTo(m_entry.SensesOS.Select(s => s.Hvo)), + "each sense renders its own header, with no section header over them"); } [Test] @@ -1329,27 +1331,55 @@ public void Compose_BooleanFields_RenderAsEditableCheckbox() "the closed enum combo over the same field stays out of scope"); } + // LT-22689: as in the WinForms DataTree, no "Senses" row sits over an entry's senses. + // Each sense's header sits at the entry's own level, and a subsense's indents under it. [Test] - public void Compose_SenseHeaders_UseHierarchicalNumbering_WithGlossSummary() + public void Compose_Senses_HaveNoBanner_AndSubsensesIndentUnderTheirSense() { + ILexSense subsense = null; NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => { - var sub = Cache.ServiceLocator.GetInstance().Create(); + subsense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS[0].SensesOS.Add(subsense); + subsense.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("hut", Cache.DefaultAnalWs)); + }); + + var senseHeaders = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses").ToList(); + + Assert.That(senseHeaders.Any(h => h.ObjectHvo == m_entry.Hvo), Is.False, + "no section header over the entry's senses"); + var senseHeader = senseHeaders.Single(h => h.ObjectHvo == m_entry.SensesOS[0].Hvo); + var subsenseHeader = senseHeaders.Single(h => h.ObjectHvo == subsense.Hvo); + Assert.That(senseHeader.Indent, Is.EqualTo(0), "a sense header sits at the entry's own level"); + Assert.That(subsenseHeader.Indent, Is.GreaterThan(senseHeader.Indent), + "a subsense header indents under its sense"); + } + + // LT-22689: sense headers read "Sense 1" and "Sense 1.1" while expanded, and add the + // gloss and grammatical info while collapsed, as the WinForms sense summary does. + [Test] + public void Compose_SenseHeaders_UseHierarchicalNumbers_AndTheGlossWhileCollapsed() + { + ILexSense sub = null; + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + sub = Cache.ServiceLocator.GetInstance().Create(); m_entry.SensesOS[0].SensesOS.Add(sub); sub.Gloss.set_String(Cache.DefaultAnalWs, TsStringUtils.MakeString("subgloss", Cache.DefaultAnalWs)); }); - var fields = DetailComposer.Compose(m_entry, Cache).Model.Fields; - var headers = fields.Where(f => f.Kind == DetailFieldKind.Header).Select(f => f.Label).ToList(); - - Assert.That(headers.Any(h => h.StartsWith("1 ") || h.StartsWith("1 ") || h.StartsWith("1 ", System.StringComparison.Ordinal) || h.StartsWith("1")), Is.True, - "top senses are numbered: " + string.Join(" | ", headers)); - Assert.That(headers.Any(h => h.StartsWith("1.1")), Is.True, - "subsenses use hierarchical numbers (1.1): " + string.Join(" | ", headers)); - Assert.That(headers.Any(h => h.Contains("gloss 1")), Is.True, - "sense headers carry the gloss summary like legacy sense lines"); - Assert.That(fields.First(f => f.Label != null && f.Label.StartsWith("1.1")).IsCollapsible, Is.True, - "sense items are collapsible"); + var senseHeaders = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.Kind == DetailFieldKind.Header && f.Field == "Senses").ToList(); + var top = senseHeaders.Single(h => h.ObjectHvo == m_entry.SensesOS[0].Hvo); + var nested = senseHeaders.Single(h => h.ObjectHvo == sub.Hvo); + var gloss = m_entry.SensesOS[0].Gloss.get_String(Cache.DefaultAnalWs).Text; + + Assert.That(top.Label, Is.EqualTo("Sense 1")); + Assert.That(nested.Label, Is.EqualTo("Sense 1.1")); + Assert.That(top.CollapsedLabel, Does.StartWith("Sense 1 - " + gloss + " - ")); + Assert.That(nested.CollapsedLabel, Does.StartWith("Sense 1.1 - subgloss - ")); + Assert.That(nested.IsCollapsible, Is.True); } [Test] diff --git a/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs new file mode 100644 index 0000000000..689018f41f --- /dev/null +++ b/Src/xWorks/xWorksTests/Avalonia/Composer/DetailSummaryLinesTests.cs @@ -0,0 +1,231 @@ +// 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.Linq; +using NUnit.Framework; +using SIL.FieldWorks.Common.FwAvalonia.Detail; +using SIL.LCModel; +using SIL.LCModel.Core.KernelInterfaces; +using SIL.LCModel.Core.Text; +using SIL.LCModel.Infrastructure; + +namespace SIL.FieldWorks.XWorks +{ + /// + /// The expanded and collapsed header lines of senses, etymologies, extended notes and + /// notebook records (LT-22689), checked against the WinForms Summary and SummaryCollapsed + /// layouts they follow, and where the composer places those headers. + /// + [TestFixture] + public class DetailSummaryLinesTests : MemoryOnlyBackendProviderTestBase + { + private ILexEntry m_entry; + private ILexSense m_sense; + + public override void TestSetup() + { + base.TestSetup(); + NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => + { + m_entry = Cache.ServiceLocator.GetInstance().Create(); + var morph = Cache.ServiceLocator.GetInstance().Create(); + m_entry.LexemeFormOA = morph; + morph.Form.set_String(Cache.DefaultVernWs, TsStringUtils.MakeString("casa", Cache.DefaultVernWs)); + m_sense = Cache.ServiceLocator.GetInstance().Create(); + m_entry.SensesOS.Add(m_sense); + m_sense.Gloss.set_String(Cache.DefaultAnalWs, Analysis("house")); + }); + } + + private ITsString Analysis(string text) => TsStringUtils.MakeString(text, Cache.DefaultAnalWs); + + private void Do(System.Action action) => NonUndoableUnitOfWorkHelper.Do(Cache.ActionHandlerAccessor, () => action()); + + private (string Expanded, string Collapsed) Lines(ICmObject obj) + { + Assert.That(new DetailSummaryLines(Cache).TryGet(obj, out var expanded, out var collapsed), Is.True); + return (expanded, collapsed); + } + + private IPartOfSpeech PartOfSpeech(string abbreviation) + { + if (Cache.LangProject.PartsOfSpeechOA == null) + Cache.LangProject.PartsOfSpeechOA = Cache.ServiceLocator.GetInstance().Create(); + var pos = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(pos); + pos.Abbreviation.set_String(Cache.DefaultAnalWs, Analysis(abbreviation)); + return pos; + } + + private void SetGrammaticalInfo(IMoMorphSynAnalysis msa) + { + m_entry.MorphoSyntaxAnalysesOC.Add(msa); + m_sense.MorphoSyntaxAnalysisRA = msa; + } + + private string Collapsed(System.Action arrange) + { + Do(() => arrange(m_entry, m_sense)); + return Lines(m_sense).Collapsed; + } + + [Test] + public void Sense_ShowsItsNumber_AndWhileCollapsed_TheGlossAndPartOfSpeech() + { + Do(() => + { + var stem = Cache.ServiceLocator.GetInstance().Create(); + SetGrammaticalInfo(stem); + stem.PartOfSpeechRA = PartOfSpeech("n"); + }); + + var lines = Lines(m_sense); + + Assert.That(lines.Expanded, Is.EqualTo("Sense 1")); + Assert.That(lines.Collapsed, Is.EqualTo("Sense 1 - house - n")); + } + + [Test] + public void Sense_WhileCollapsed_ShowsTheDefinitionInPlaceOfTheGloss() + { + var collapsed = Collapsed((entry, sense) => + sense.Definition.set_String(Cache.DefaultAnalWs, Analysis("a dwelling"))); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - a dwelling - ")); + } + + [Test] + public void GrammaticalInfo_OfADerivationalAffix_ShowsFromAndToPartsOfSpeech() + { + var collapsed = Collapsed((entry, sense) => + { + var msa = Cache.ServiceLocator.GetInstance().Create(); + SetGrammaticalInfo(msa); + msa.FromPartOfSpeechRA = PartOfSpeech("v"); + msa.ToPartOfSpeechRA = PartOfSpeech("n"); + }); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - house - v > n")); + } + + [Test] + public void GrammaticalInfo_OfADerivationalAffix_WithNoPartsOfSpeech_IsUnclassified() + { + var collapsed = Collapsed((entry, sense) => + SetGrammaticalInfo(Cache.ServiceLocator.GetInstance().Create())); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - house - Unclassified affix")); + } + + [Test] + public void GrammaticalInfo_OfAnInflectionalAffix_ShowsItsPartOfSpeechAndSlots() + { + var collapsed = Collapsed((entry, sense) => + { + var pos = PartOfSpeech("v"); + var slot = Cache.ServiceLocator.GetInstance().Create(); + pos.AffixSlotsOC.Add(slot); + slot.Name.set_String(Cache.DefaultAnalWs, Analysis("tense")); + var msa = Cache.ServiceLocator.GetInstance().Create(); + SetGrammaticalInfo(msa); + msa.PartOfSpeechRA = pos; + msa.SlotsRC.Add(slot); + }); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - house - v:tense")); + } + + [Test] + public void GrammaticalInfo_OfAnInflectionalAffix_WithNoPartOfSpeech_IsAnUnmarkedSlot() + { + var collapsed = Collapsed((entry, sense) => + SetGrammaticalInfo(Cache.ServiceLocator.GetInstance().Create())); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - house - Unmk. infl. afx slot")); + } + + [Test] + public void GrammaticalInfo_OfAnUnclassifiedAffix_ShowsItsPartOfSpeech() + { + var collapsed = Collapsed((entry, sense) => + { + var msa = Cache.ServiceLocator.GetInstance().Create(); + SetGrammaticalInfo(msa); + msa.PartOfSpeechRA = PartOfSpeech("adj"); + }); + + Assert.That(collapsed, Is.EqualTo("Sense 1 - house - adj")); + } + + [Test] + public void Etymology_WhileCollapsed_ShowsTheGloss_AndAnyLanguageNotesBeforeIt() + { + ILexEtymology etymology = null; + Do(() => + { + etymology = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EtymologyOS.Add(etymology); + etymology.Gloss.set_String(Cache.DefaultAnalWs, Analysis("borrowed")); + }); + Assert.That(Lines(etymology), Is.EqualTo(("Etymology", "Etymology - borrowed"))); + + Do(() => etymology.LanguageNotes.set_String(Cache.DefaultAnalWs, Analysis("from Latin"))); + Assert.That(Lines(etymology).Collapsed, Is.EqualTo("Etymology - from Latin - borrowed")); + } + + [Test] + public void ExtendedNote_WhileCollapsed_ShowsItsTypeAndDiscussion() + { + ILexExtendedNote note = null; + Do(() => + { + if (Cache.LangProject.LexDbOA.ExtendedNoteTypesOA == null) + Cache.LangProject.LexDbOA.ExtendedNoteTypesOA = + Cache.ServiceLocator.GetInstance().Create(); + var type = Cache.ServiceLocator.GetInstance().Create(); + Cache.LangProject.LexDbOA.ExtendedNoteTypesOA.PossibilitiesOS.Add(type); + type.Abbreviation.set_String(Cache.DefaultAnalWs, Analysis("use")); + note = Cache.ServiceLocator.GetInstance().Create(); + m_sense.ExtendedNoteOS.Add(note); + note.ExtendedNoteTypeRA = type; + note.Discussion.set_String(Cache.DefaultAnalWs, Analysis("formal speech")); + }); + + Assert.That(Lines(note), Is.EqualTo(("Extended Note", "Extended Note - use - formal speech"))); + } + + [Test] + public void Compose_EtymologiesAndExtendedNotes_HaveNoBanner_AndCarryTheirSummaryLines() + { + ILexEtymology etymology = null; + ILexExtendedNote note = null; + Do(() => + { + etymology = Cache.ServiceLocator.GetInstance().Create(); + m_entry.EtymologyOS.Add(etymology); + etymology.Gloss.set_String(Cache.DefaultAnalWs, Analysis("borrowed")); + note = Cache.ServiceLocator.GetInstance().Create(); + m_sense.ExtendedNoteOS.Add(note); + note.Discussion.set_String(Cache.DefaultAnalWs, Analysis("formal speech")); + }); + + var headers = DetailComposer.Compose(m_entry, Cache).Model.Fields + .Where(f => f.Kind == DetailFieldKind.Header).ToList(); + + Assert.That(headers.Any(h => h.Field == "Etymology" && h.ObjectHvo == m_entry.Hvo), Is.False, + "no banner over the etymologies"); + Assert.That(headers.Any(h => h.Field == "ExtendedNote" && h.ObjectHvo == m_sense.Hvo), Is.False, + "no banner over the extended notes"); + var etymologyHeader = headers.Single(h => h.ObjectHvo == etymology.Hvo); + Assert.That((etymologyHeader.Label, etymologyHeader.CollapsedLabel), Is.EqualTo(Lines(etymology))); + Assert.That(etymologyHeader.Indent, Is.EqualTo(0), "an etymology header sits at the entry's level"); + var noteHeader = headers.Single(h => h.ObjectHvo == note.Hvo); + Assert.That((noteHeader.Label, noteHeader.CollapsedLabel), Is.EqualTo(Lines(note))); + Assert.That(etymologyHeader.IsNormalWeight && noteHeader.IsNormalWeight, Is.True, + "etymologies and extended notes open with a NormalSummary row"); + Assert.That(headers.Single(h => h.ObjectHvo == m_sense.Hvo).IsNormalWeight, Is.False, + "a sense opens with a HeavySummary row"); + } + } +} diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs index 430d64dee9..21873ad052 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuAuthorityTests.cs @@ -42,7 +42,7 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase // Lexeme Form, Citation Form and variant form rows. "mnuDataTree-LexemeForm", "mnuDataTree-LexemeFormContext", "mnuDataTree-CitationFormContext", "mnuDataTree-VariantFormContext", - // Lexicon rows: the Subsenses and Extended Notes section rows, and the hotlinks of + // Lexicon rows: the Subsenses and Extended Notes prompt rows, and the hotlinks of // a sense, an extended note and an etymology. "mnuDataTree-Subsenses", "mnuDataTree-ExtendedNotes", "mnuDataTree-Sense-Hotlinks", "mnuDataTree-ExtendedNote-Hotlinks", "mnuDataTree-Etymology-Hotlinks", @@ -74,8 +74,8 @@ public class LexiconMenuAuthorityTests : XWorksAppTestBase private static readonly string[] LexiconLabelMenuIds = { "mnuDataTree-Translation", "mnuDataTree-ComplexFormSpec", "mnuDataTree-LexemeForm", - "mnuDataTree-Subsenses", "mnuDataTree-ExtendedNotes", "mnuDataTree-AlternateForms", - "mnuDataTree-VariantForms", "mnuDataTree-Examples" + "mnuDataTree-Subsenses", "mnuDataTree-AlternateForms", "mnuDataTree-VariantForms", + "mnuDataTree-Examples" }; private static readonly string[] LexiconInStringMenuIds = @@ -294,8 +294,8 @@ public void OwnedMenus_AreBuiltWithoutTheAdapterOrTheMediator() { Assert.That(HiddenTreeExists, Is.False, "precondition: no hidden tree yet"); var rows = OwnedMenuRows(); - Assert.That(rows, Has.Count.EqualTo(18), - "precondition: sixteen ids, -Translation and -VariantForms on two rows each"); + Assert.That(rows, Has.Count.EqualTo(17), + "precondition: fifteen ids, -Translation and -VariantForms on two rows each"); foreach (var (row, menuId, kind) in rows) { var ids = kind == DetailMenuKind.ContextMenu @@ -416,7 +416,7 @@ public void InsertLeaf_IsOfferedOnTheGhostRow_DisabledWithoutATarget_AndAbsentWi var subsense = Leaf("mnuDataTree-Subsenses", "CmdDataTree-Insert-SubSense"); var extendedNote = Leaf("mnuDataTree-ExtendedNotes", "CmdDataTree-Insert-ExtNote"); var subsensesRow = Rows().Single(f => f.MenuId == "mnuDataTree-Subsenses"); - var notesRow = Rows().Single(f => f.MenuId == "mnuDataTree-ExtendedNotes"); + var senseRow = Rows().First(f => f.ObjectHvo == m_entry.SensesOS[0].Hvo && f.Kind == DetailFieldKind.Header); var etymologyRow = Rows().First(f => f.ObjectHvo == m_etymology.Hvo); var noObject = new DetailField("Row", "Row", "Senses", null, DetailFieldKind.Header, EditorClassification.GroupingNone, null, null, HostRouting.Inherit, null, null, null, menuId: subsensesRow.MenuId); @@ -425,7 +425,7 @@ public void InsertLeaf_IsOfferedOnTheGhostRow_DisabledWithoutATarget_AndAbsentWi var onGhost = m_view.InsertItem(subsense, subsensesRow); Assert.That(onGhost.IsEnabled, Is.True, "the ghost row inserts into the sense it stands in for"); Assert.That(onGhost.Label, Is.EqualTo("Insert Subsense")); - Assert.That(m_view.InsertItem(extendedNote, notesRow).IsEnabled, Is.True, "the sense has ExtendedNote"); + Assert.That(m_view.InsertItem(extendedNote, senseRow).IsEnabled, Is.True, "the sense has ExtendedNote"); var noTarget = m_view.InsertItem(extendedNote, etymologyRow); Assert.That(noTarget.IsEnabled, Is.False, "neither the entry nor an etymology has ExtendedNote"); Assert.That(noTarget.Execute, Is.Null); diff --git a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs index c98f27b06c..63291fb58c 100644 --- a/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs +++ b/Src/xWorks/xWorksTests/Avalonia/Hosting/LexiconMenuExecutionTests.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Xml; using NUnit.Framework; using SIL.FieldWorks.Common.Controls; @@ -358,16 +359,16 @@ public void InsertExtendedNote_FromTheFirstNotesHotlinks_GoesRightAfterIt() Assert.That(m_sense1.ExtendedNoteOS[2], Is.SameAs(m_note2), "the new note sits between the two"); } - // The Extended Notes section shows only with data, so no prompt row inserts the first. + // No section row sits over a sense's notes; the sense's own menu inserts one last. [Test] - public void InsertExtendedNote_FromTheSectionRow_GoesLast() + public void InsertExtendedNote_FromTheSenseMenu_GoesLast() { - var section = Rows(m_entry).Single(f => f.MenuId == "mnuDataTree-ExtendedNotes" && f.ObjectHvo == m_sense1.Hvo); - Assert.That(section.Kind, Is.EqualTo(DetailFieldKind.Header), "precondition: the section header binds the id"); - Assert.That(Rows(m_entry).Any(f => f.MenuId == "mnuDataTree-ExtendedNotes" && f.ObjectHvo == m_sense2.Hvo), Is.False, - "precondition: the second sense has no note, so no section row"); + var sense = HeaderRowOf(m_sense1); + Assert.That(sense.MenuId, Is.EqualTo("mnuDataTree-Sense"), "precondition: the sense header binds its menu"); + Assert.That(Rows(m_entry).Any(f => f.MenuId == "mnuDataTree-ExtendedNotes"), Is.False, + "precondition: no section row over the notes"); - InvokeLabelMenuItem(section, "Insert Extended Note"); + InvokeLabelMenuItem(sense, "Insert Extended Note"); Assert.That(m_sense1.ExtendedNoteOS, Has.Count.EqualTo(3)); Assert.That(m_sense1.ExtendedNoteOS[1], Is.SameAs(m_note2), "the new note is last"); @@ -505,7 +506,14 @@ private void Invoke(string[] ids, DetailField row, DetailMenuKind kind, string l { var window = m_propertyTable.GetValue("window"); var request = DetailMenuRequest.FromAnchor(null, row, kind); - var items = XCoreMenuBridge.CreateMenuItems(window, ids, null, null, m_view.CreateMenuAuthority(request)); + var authority = m_view.CreateMenuAuthority(request); + // As in the host, a menu no authority fully owns targets the adapter at the row. + if (!XCoreMenuBridge.OwnsAll(authority, ids)) + { + typeof(RecordEditView).GetMethod("SyncMenuCommandAdapter", BindingFlags.Instance | BindingFlags.NonPublic) + .Invoke(m_view, new object[] { row }); + } + var items = XCoreMenuBridge.CreateMenuItems(window, ids, null, null, authority); var item = items.FirstOrDefault(i => i.Label == label); Assert.That(item, Is.Not.Null, "the menu offers '{0}'", label); Assert.That(item.Execute, Is.Not.Null, "'{0}' is enabled", label);