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
28 changes: 4 additions & 24 deletions Src/XCore/xCoreInterfaces/ChoiceGroup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,6 @@ public string ListId
}
}
protected override void Populate()
{
Populate(querySubmenuVisibility: true);
}

private void Populate(bool querySubmenuVisibility)
{
Clear();
if (IsAListGroup)
Expand All @@ -442,12 +437,12 @@ private void Populate(bool querySubmenuVisibility)
{
foreach (XmlNode n in m_configurationNodes)
{
Populate(n, querySubmenuVisibility);
Populate(n);
}
}
else
{
Populate(m_configurationNode, querySubmenuVisibility);
Populate(m_configurationNode);
}
}

Expand All @@ -460,16 +455,6 @@ public void PopulateNow()
Populate();
}

/// <summary>
/// Populates the group, keeping every nested submenu when
/// <paramref name="querySubmenuVisibility"/> is false instead of asking the colleagues
/// whether one of its items is visible; the caller then decides the submenu's fate.
/// </summary>
public void PopulateNow(bool querySubmenuVisibility)
{
Populate(querySubmenuVisibility);
}

protected void PopulateFromList()
{
/// Just before this group is displayed, allow the group's contents to be modified by colleagues
Expand Down Expand Up @@ -536,11 +521,6 @@ public bool HasSubGroups()
}

protected void Populate(XmlNode node)
{
Populate(node, querySubmenuVisibility: true);
}

private void Populate(XmlNode node, bool querySubmenuVisibility)
{
Debug.Assert( node != null);
XmlNodeList items = node.SelectNodes("item | menu | group");
Expand All @@ -554,11 +534,11 @@ private void Populate(XmlNode node, bool querySubmenuVisibility)
break;
case "menu":
ChoiceGroup group = new ChoiceGroup(m_mediator, m_propertyTable, m_adapter, childNode, this);
group.Populate(childNode, querySubmenuVisibility);
group.Populate(childNode);
//Only add the submenu if it contains a list of items what will be visible.
//We do not want an empty submenu LT-8791.
string hasList = XmlUtils.GetAttributeValue(childNode, "list");
if (hasList != null || !querySubmenuVisibility || ASubmenuItemIsVisible(group))
if (hasList != null || ASubmenuItemIsVisible(group))
this.Add(group);
break;
case "group": //for tree views in the sidebar
Expand Down
3 changes: 1 addition & 2 deletions Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
using System;
using System.Collections.Generic;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using XCore;

namespace SIL.FieldWorks.XWorks
{
Expand All @@ -25,7 +24,7 @@ public CompositeMenuAuthority(params IDetailMenuAuthority[] members)

public bool Owns(string menuId) => Owner(menuId) != null;

public DetailMenuItem Build(string menuId, ChoiceBase leaf)
public DetailMenuItem Build(string menuId, DetailMenuLeaf leaf)
=> Owning(menuId).Build(menuId, leaf);

public IReadOnlyList<DetailMenuItem> BuildList(string menuId, string listId)
Expand Down
126 changes: 126 additions & 0 deletions Src/xWorks/Avalonia/Hosting/DetailMenuDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// 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 System.Collections.Generic;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// The item tree of one context-menu id as its configuration defines it: separators,
/// command leaves, submenus (a list-populated one carrying only its list id), and the
/// items no authority can answer. Built by <see cref="XCoreMenuBridge.ResolveMenu"/>; an
/// owned menu is rendered from it and an authority decides ownership from it.
/// </summary>
public sealed class DetailMenuDefinition
{
private readonly List<DetailMenuLeaf> _leaves = new List<DetailMenuLeaf>();
private readonly List<string> _listIds = new List<string>();
private readonly List<string> _unanswerable = new List<string>();

public DetailMenuDefinition(string menuId, IReadOnlyList<DetailMenuEntry> entries)
{
MenuId = menuId ?? throw new ArgumentNullException(nameof(menuId));
Entries = entries ?? throw new ArgumentNullException(nameof(entries));
Collect(entries);
}

/// <summary>The menu id the definition was resolved from.</summary>
public string MenuId { get; }

/// <summary>The top-level entries in configuration order.</summary>
public IReadOnlyList<DetailMenuEntry> Entries { get; }

/// <summary>Every command leaf, submenus included, in configuration order.</summary>
public IReadOnlyList<DetailMenuLeaf> Leaves => _leaves;

/// <summary>The list ids of the list-populated submenus, submenus included.</summary>
public IReadOnlyList<string> ListIds => _listIds;

/// <summary>
/// Why each item no authority can answer cannot be answered, submenus included: a
/// property toggle belongs to the property table, and an undefined command has no
/// message. A menu with any such item is never owned.
/// </summary>
public IReadOnlyList<string> Unanswerable => _unanswerable;

private void Collect(IReadOnlyList<DetailMenuEntry> entries)
{
foreach (var entry in entries)
{
if (entry.Leaf != null)
_leaves.Add(entry.Leaf);
else if (entry.Unanswerable != null)
_unanswerable.Add(entry.Unanswerable);
else if (entry.ListId != null)
_listIds.Add(entry.ListId);
else if (entry.Children != null)
Collect(entry.Children);
}
}
}

/// <summary>
/// One entry of a <see cref="DetailMenuDefinition"/>: a separator, a command leaf, an
/// item no authority can answer, or a submenu whose children are configured items or, for
/// a list-populated one, come from the authority at build time.
/// </summary>
public sealed class DetailMenuEntry
{
private DetailMenuEntry(DetailMenuLeaf leaf, string label, string unanswerable, string listId,
bool isInline, IReadOnlyList<DetailMenuEntry> children)
{
Leaf = leaf;
Label = label ?? string.Empty;
Unanswerable = unanswerable;
ListId = listId;
IsInline = isInline;
Children = children;
}

public static DetailMenuEntry Separator() => new DetailMenuEntry(null, null, null, null, false, null);

public static DetailMenuEntry ForLeaf(DetailMenuLeaf leaf)
=> new DetailMenuEntry(leaf ?? throw new ArgumentNullException(nameof(leaf)), null, null, null, false, null);

/// <param name="label">The item's localized label with its accelerator marker.</param>
/// <param name="reason">Why no authority can answer the item.</param>
public static DetailMenuEntry ForUnanswerable(string label, string reason)
=> new DetailMenuEntry(null, label, reason ?? throw new ArgumentNullException(nameof(reason)),
null, false, null);

/// <param name="label">The submenu's localized label with its accelerator marker.</param>
/// <param name="isInline">Whether its items splice into the parent instead of
/// nesting.</param>
public static DetailMenuEntry ForSubmenu(string label, bool isInline, IReadOnlyList<DetailMenuEntry> children)
=> new DetailMenuEntry(null, label, null, null, isInline,
children ?? throw new ArgumentNullException(nameof(children)));

/// <param name="listId">The <c>list</c> attribute naming the items' source.</param>
public static DetailMenuEntry ForList(string label, bool isInline, string listId)
=> new DetailMenuEntry(null, label, null, listId ?? throw new ArgumentNullException(nameof(listId)),
isInline, null);

public bool IsSeparator => Leaf == null && Unanswerable == null && Children == null && ListId == null;

/// <summary>The command leaf, or null for any other entry.</summary>
public DetailMenuLeaf Leaf { get; }

/// <summary>A submenu's or unanswerable item's localized label; empty
/// otherwise.</summary>
public string Label { get; }

/// <summary>Why no authority can answer this item; null for any other entry.</summary>
public string Unanswerable { get; }

/// <summary>A list-populated submenu's list id; null otherwise.</summary>
public string ListId { get; }

/// <summary>Whether a submenu's items splice into the parent menu.</summary>
public bool IsInline { get; }

/// <summary>A configured submenu's entries; null for a leaf, separator or list.</summary>
public IReadOnlyList<DetailMenuEntry> Children { get; }
}
}
38 changes: 38 additions & 0 deletions Src/xWorks/Avalonia/Hosting/DetailMenuLeaf.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// 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 XCore;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// One command leaf of a context menu as its configuration defines it: the resolved
/// <see cref="XCore.Command"/> and the menu text. Built by the bridge's walk of the menu XML,
/// so no xCore choice object and no mediator query stands behind it.
/// </summary>
public sealed class DetailMenuLeaf
{
/// <param name="command">The command the item's <c>command</c> attribute names.</param>
/// <param name="label">The localized menu text, the item's own label when it has one,
/// else the command's; the accelerator marker is kept.</param>
public DetailMenuLeaf(Command command, string label)
{
Command = command ?? throw new ArgumentNullException(nameof(command));
Label = label ?? string.Empty;
}

/// <summary>The resolved command, for answers that call its target's object UI.</summary>
public Command Command { get; }

/// <summary>The command id, e.g. <c>CmdDataTree-Help</c>.</summary>
public string CommandId => Command.Id;

/// <summary>The message the command sends, e.g. <c>DataTreeDelete</c>.</summary>
public string Message => Command.Message;

/// <summary>The localized menu text with its accelerator marker.</summary>
public string Label { get; }
}
}
9 changes: 4 additions & 5 deletions Src/xWorks/Avalonia/Hosting/EnvironmentInsertMenuAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
using System.Collections.Generic;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.Reporting;
using XCore;

namespace SIL.FieldWorks.XWorks
{
Expand Down Expand Up @@ -38,17 +37,17 @@ public bool Owns(string menuId)
=> string.Equals(menuId, EnvironmentsMenuId, StringComparison.Ordinal)
|| string.Equals(menuId, StringRepresentationMenuId, StringComparison.Ordinal);

public DetailMenuItem Build(string menuId, ChoiceBase leaf)
public DetailMenuItem Build(string menuId, DetailMenuLeaf leaf)
{
if (leaf == null)
throw new ArgumentNullException(nameof(leaf));
if (!(leaf is CommandChoice command) || !EnvironmentMenuLeaves.IsInsertMessage(command.Message))
if (!EnvironmentMenuLeaves.IsInsertMessage(leaf.Message))
{
throw new InvalidOperationException(string.Format(
"Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.HelpId));
"Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.CommandId));
}
LogUnsupportedRowOnce();
return EnvironmentMenuLeaves.BuildInsert(command.Message, XCoreMenuBridge.StripAccelerator(leaf.Label),
return EnvironmentMenuLeaves.BuildInsert(leaf.Message, XCoreMenuBridge.StripAccelerator(leaf.Label),
_request, _host);
}

Expand Down
6 changes: 2 additions & 4 deletions Src/xWorks/Avalonia/Hosting/IDetailMenuAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

using System.Collections.Generic;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using XCore;

namespace SIL.FieldWorks.XWorks
{
Expand All @@ -28,12 +27,11 @@ public interface IDetailMenuAuthority
/// The rendered item for a leaf under an owned menu id, or null when the leaf is hidden.
/// </summary>
/// <param name="menuId">The owned menu id the leaf belongs to.</param>
/// <param name="leaf">The xCore leaf; its label is the menu text, its
/// <see cref="ChoiceBase.HelpId"/> the command id.</param>
/// <param name="leaf">The configured leaf: its command, message and menu text.</param>
/// <exception cref="System.InvalidOperationException">The leaf's command is not one the
/// authority answers: an owned id must be answered in full, or a leaf would leak as
/// visible-but-disabled.</exception>
DetailMenuItem Build(string menuId, ChoiceBase leaf);
DetailMenuItem Build(string menuId, DetailMenuLeaf leaf);

/// <summary>
/// The items of a list-populated submenu under an owned menu id, in display order, each
Expand Down
87 changes: 87 additions & 0 deletions Src/xWorks/Avalonia/Hosting/LexiconMenuAuthority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// 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 System.Collections.Generic;
using SIL.FieldWorks.Common.FwAvalonia.Detail;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// The native authority for the Lexicon detail menus, answered by the message each
/// command sends (Insert, Delete, Move, Merge and the rest) and parameterized by the
/// command's configuration. It owns a menu id by predicate: when it has an answerer for
/// the message of every leaf under the id and the id carries neither a list-populated
/// submenu nor an item no authority can answer. Adding an answerer therefore switches
/// over every id it completes, and the ownership test pins the resulting list.
/// </summary>
internal sealed class LexiconMenuAuthority : IDetailMenuAuthority
{
private readonly Func<string, DetailMenuDefinition> _resolve;
private readonly Dictionary<string, DetailMenuDefinition> _resolved
= new Dictionary<string, DetailMenuDefinition>(StringComparer.Ordinal);
private readonly Dictionary<string, Func<DetailMenuLeaf, DetailMenuItem>> _answerers
= new Dictionary<string, Func<DetailMenuLeaf, DetailMenuItem>>(StringComparer.Ordinal);

/// <param name="resolve">Resolves a menu id to its configured item tree.</param>
public LexiconMenuAuthority(Func<string, DetailMenuDefinition> resolve)
{
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
}

/// <summary>The messages this authority answers, for the ownership test.</summary>
internal IEnumerable<string> AnsweredMessages => _answerers.Keys;

/// <summary>
/// Registers the answerer for every leaf whose command sends <paramref name="message"/>;
/// the ids it completes become owned. One answerer per message.
/// </summary>
internal void Answer(string message, Func<DetailMenuLeaf, DetailMenuItem> answerer)
{
if (string.IsNullOrEmpty(message))
throw new ArgumentException("A message is required.", nameof(message));
_answerers.Add(message, answerer ?? throw new ArgumentNullException(nameof(answerer)));
}

public bool Owns(string menuId)
{
var menu = Resolve(menuId);
if (menu.Leaves.Count == 0 || menu.ListIds.Count > 0 || menu.Unanswerable.Count > 0)
return false;
foreach (var leaf in menu.Leaves)
{
if (!_answerers.ContainsKey(leaf.Message))
return false;
}
return true;
}

public DetailMenuItem Build(string menuId, DetailMenuLeaf leaf)
{
if (leaf == null)
throw new ArgumentNullException(nameof(leaf));
if (_answerers.TryGetValue(leaf.Message, out var answer))
return answer(leaf);
throw new InvalidOperationException(string.Format(
"Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.CommandId));
}

// An id with a list-populated submenu is never owned.
public IReadOnlyList<DetailMenuItem> BuildList(string menuId, string listId)
=> throw new InvalidOperationException(string.Format(
"Menu '{0}' has a list submenu '{1}' this authority does not answer.", menuId, listId));

// Resolved once per id for the authority's lifetime, one menu request.
private DetailMenuDefinition Resolve(string menuId)
{
if (!_resolved.TryGetValue(menuId, out var menu))
{
menu = _resolve(menuId) ?? throw new InvalidOperationException(string.Format(
"Menu '{0}' did not resolve.", menuId));
_resolved.Add(menuId, menu);
}
return menu;
}
}
}
Loading
Loading