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
45 changes: 45 additions & 0 deletions Src/xWorks/Avalonia/Hosting/CompositeMenuAuthority.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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 SIL.FieldWorks.Common.FwAvalonia.Detail;
using XCore;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// One authority over several: a menu id is answered by the first member that owns it.
/// </summary>
internal sealed class CompositeMenuAuthority : IDetailMenuAuthority
{
private readonly IDetailMenuAuthority[] _members;

public CompositeMenuAuthority(params IDetailMenuAuthority[] members)
{
_members = members ?? throw new ArgumentNullException(nameof(members));
if (Array.IndexOf(_members, null) >= 0)
throw new ArgumentException("A member authority is null.", nameof(members));
}

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

public DetailMenuItem Build(string menuId, ChoiceBase leaf)
{
var owner = Owner(menuId);
if (owner == null)
throw new InvalidOperationException(string.Format("No authority owns menu '{0}'.", menuId));
return owner.Build(menuId, leaf);
}

private IDetailMenuAuthority Owner(string menuId)
{
foreach (var member in _members)
{
if (member.Owns(menuId))
return member;
}
return null;
}
}
}
131 changes: 131 additions & 0 deletions Src/xWorks/Avalonia/Hosting/ObjectMenuAuthority.cs
Original file line number Diff line number Diff line change
@@ -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;
using SIL.FieldWorks.Common.FwAvalonia.Detail;
using SIL.FieldWorks.Common.FwAvalonia.ViewDefinition;
using XCore;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// The native authority for the per-object menu every detail row merges (Field Visibility,
/// Move Field, Help) and for the empty Help menu most rows bind. Field Visibility and Move
/// Field act on the row's node in the project override layer; Help opens the row's own
/// topic. A row whose override target cannot be located keeps those items, disabled.
/// </summary>
internal sealed class ObjectMenuAuthority : IDetailMenuAuthority
{
internal const string MenuId = RecordEditView.ObjectMenuId;
internal const string HelpMenuId = "mnuDataTree-Help";
internal const string AlwaysVisibleCommandId = "CmdAlwaysVisible";
internal const string IfDataCommandId = "CmdIfData";
internal const string NormallyHiddenCommandId = "CmdNormallyHidden";
internal const string MoveFieldUpCommandId = "CmdDataTree-MoveFieldUp";
internal const string MoveFieldDownCommandId = "CmdDataTree-MoveFieldDown";
internal const string HelpCommandId = "CmdDataTree-Help";

private readonly Lazy<OverrideTarget> _target;
private readonly Func<string, OverrideTarget, ViewVisibility, DetailMenuItem> _fieldVisibility;
private readonly Func<string, OverrideTarget, bool, DetailMenuItem> _moveField;
private readonly Lazy<string> _helpTopic;
private readonly Action<string> _showHelp;

/// <summary>Creates the authority for one row's menu.</summary>
/// <param name="field">The row.</param>
/// <param name="locateTarget">The row's override target, or null when it cannot be
/// located; asked once, on the first Field Visibility or Move Field leaf.</param>
/// <param name="fieldVisibility">Builds a Field Visibility item from (label, target,
/// visibility).</param>
/// <param name="moveField">Builds a Move Field item from (label, target, up).</param>
/// <param name="helpTopic">The row's help topic when the help provider has it, else
/// null; asked once.</param>
/// <param name="showHelp">Opens a help topic.</param>
public ObjectMenuAuthority(DetailField field, Func<DetailField, OverrideTarget> locateTarget,
Func<string, OverrideTarget, ViewVisibility, DetailMenuItem> fieldVisibility,
Func<string, OverrideTarget, bool, DetailMenuItem> moveField,
Func<DetailField, string> helpTopic, Action<string> showHelp)
{
if (field == null)
throw new ArgumentNullException(nameof(field));
if (locateTarget == null)
throw new ArgumentNullException(nameof(locateTarget));
if (helpTopic == null)
throw new ArgumentNullException(nameof(helpTopic));
_fieldVisibility = fieldVisibility ?? throw new ArgumentNullException(nameof(fieldVisibility));
_moveField = moveField ?? throw new ArgumentNullException(nameof(moveField));
_showHelp = showHelp ?? throw new ArgumentNullException(nameof(showHelp));
_target = new Lazy<OverrideTarget>(() => locateTarget(field));
_helpTopic = new Lazy<string>(() => helpTopic(field));
}

public bool Owns(string menuId)
=> string.Equals(menuId, MenuId, StringComparison.Ordinal)
|| string.Equals(menuId, HelpMenuId, StringComparison.Ordinal);

public DetailMenuItem Build(string menuId, ChoiceBase leaf)
{
if (leaf == null)
throw new ArgumentNullException(nameof(leaf));
var label = XCoreMenuBridge.StripAccelerator(leaf.Label);
switch (leaf.HelpId)
{
case AlwaysVisibleCommandId:
return VisibilityItem(label, ViewVisibility.Always);
case IfDataCommandId:
return VisibilityItem(label, ViewVisibility.IfData);
case NormallyHiddenCommandId:
return VisibilityItem(label, ViewVisibility.Never);
case MoveFieldUpCommandId:
return MoveItem(label, up: true);
case MoveFieldDownCommandId:
return MoveItem(label, up: false);
case HelpCommandId:
return HelpItem(label);
default:
throw new InvalidOperationException(string.Format(
"Menu '{0}' has a leaf '{1}' this authority does not answer.", menuId, leaf.HelpId));
}
}

// Offered on every row; without a located target it is disabled rather than guessed.
private DetailMenuItem VisibilityItem(string label, ViewVisibility visibility)
=> _target.Value == null ? Disabled(label) : _fieldVisibility(label, _target.Value, visibility);

private DetailMenuItem MoveItem(string label, bool up)
=> _target.Value == null ? Disabled(label) : _moveField(label, _target.Value, up);

// Hidden when the help provider has no topic for the row, as WinForms hides it.
private DetailMenuItem HelpItem(string label)
{
var topic = _helpTopic.Value;
if (topic == null)
return null;
return new DetailMenuItem(label, isEnabled: true, isChecked: false, children: null,
execute: () => _showHelp(topic));
}

private static DetailMenuItem Disabled(string label)
=> new DetailMenuItem(label, isEnabled: false, isChecked: false, children: null, execute: null);
}

/// <summary>
/// A row's node in its own compiled model with the current override applied: what Field
/// Visibility reads its checkmark from and Move Field its enablement.
/// </summary>
internal sealed class OverrideTarget
{
public OverrideTarget(string templateId, ViewNodeLocation location)
{
TemplateId = templateId ?? throw new ArgumentNullException(nameof(templateId));
Location = location ?? throw new ArgumentNullException(nameof(location));
}

/// <summary>The row's template id, without its runtime suffix.</summary>
public string TemplateId { get; }

/// <summary>The node's position and visibility among its siblings.</summary>
public ViewNodeLocation Location { get; }
}
}
109 changes: 84 additions & 25 deletions Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
Original file line number Diff line number Diff line change
Expand Up @@ -515,31 +515,27 @@ private void OnDetailMenuRequested(DetailMenuRequest request)

// Only ids without a native authority need the hidden command adapter. An adapter
// failure must not suppress the menu: its items disable, the rest still works.
var authority = CreateReorderVectorAuthority(request);
if (!XCoreMenuBridge.OwnsAll(authority, idArray))
{
try
{
EnsureMenuCommandAdapter(request.Field.ObjectHvo, request.Field.Field);
}
catch (Exception adapterError)
{
Logger.WriteError("Detail menu command adapter failed; menu items that need "
+ "the hidden colleague chain will be disabled.", adapterError);
}
}
var authority = CreateMenuAuthority(request);
var ownsAll = XCoreMenuBridge.OwnsAll(authority, idArray);
if (!ownsAll)
SyncMenuCommandAdapter(request.Field);

// Render the SAME xCore menu natively in Avalonia -- identical items,
// enablement, and mediator dispatch; only rendering changes. The WinForms
// adapter menu remains the fallback if materialization fails.
try
{
// Field Visibility / Move Field retarget to the override layer; other
// mediator-answered commands keep their dispatch.
var registry = new OverrideCommandRegistry();
AddOverrideCommands(registry, request.Field);
var items = XCoreMenuBridge.CreateMenuItems(window, idArray, registry.TryBuild, null,
authority);
// On the mediator path Field Visibility / Move Field retarget to the override
// layer; other mediator-answered commands keep their dispatch. An owned menu
// never consults the interceptor, so it is not built.
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> interceptor = null;
if (!ownsAll)
{
var registry = new OverrideCommandRegistry();
AddOverrideCommands(registry, request.Field);
interceptor = registry.TryBuild;
}
var items = XCoreMenuBridge.CreateMenuItems(window, idArray, interceptor, null, authority);
if (items.Count > 0)
{
// A keyboard-opened menu anchors under the row it came from; a
Expand All @@ -555,6 +551,10 @@ private void OnDetailMenuRequested(DetailMenuRequest request)
nativeMenuError);
}

// The adapter menu answers from the hidden tree's current slice, which an owned
// menu never pointed at this row.
if (ownsAll)
SyncMenuCommandAdapter(request.Field);
window.ShowContextMenu(idArray, AdapterMenuScreenPoint(request), null, null);
}
catch (Exception e)
Expand Down Expand Up @@ -597,21 +597,20 @@ private ViewDefinitionOverrideStore ViewOverrideStore

/// <summary>
/// The help topic of a detail row: its <see cref="DetailField.HelpTopicId"/> when set,
/// else one generated from the row's field and object and the current tool. Null when
/// the row carries nothing to generate from.
/// else one generated from the row's field and object and the current tool. A row with
/// no object generates from its field and label alone, so every row ends at a topic,
/// the generic one at worst.
/// </summary>
internal string ResolveHelpTopic(DetailField field)
{
if (field == null)
return null;
var source = field.HelpTopicSource;
if (string.IsNullOrEmpty(field.HelpTopicId) && source == null)
return null;
var provider = m_propertyTable.GetValue<IHelpTopicProvider>("HelpTopicProvider");
var subject = new HelpTopicSubject
{
FieldName = source?.FieldName,
Label = source?.Label,
FieldName = source?.FieldName ?? field.Field,
Label = source?.Label ?? field.Label,
ClassName = source?.ClassName,
OwnerClassName = source?.OwnerClassName,
SortKey = source?.SortKey,
Expand Down Expand Up @@ -674,6 +673,66 @@ internal void AddOverrideCommands(OverrideCommandRegistry registry, DetailField
private static string LabelOf(UIItemDisplayProperties display)
=> XCoreMenuBridge.StripAccelerator(display.Text);

/// <summary>
/// The native authorities for the request's row: the reorder-vector menu and the shared
/// per-object and Help menus, so a label menu made only of those ids needs nothing from
/// the hidden command adapter.
/// </summary>
internal IDetailMenuAuthority CreateMenuAuthority(DetailMenuRequest request)
=> new CompositeMenuAuthority(CreateReorderVectorAuthority(request),
CreateObjectMenuAuthority(request.Field));

/// <summary>The native authority for the row's per-object and Help menus.</summary>
internal IDetailMenuAuthority CreateObjectMenuAuthority(DetailField field)
=> new ObjectMenuAuthority(field, LocateOverrideTarget,
fieldVisibility: (label, target, visibility) =>
VisibilityItem(label, field, target.TemplateId, target.Location, visibility),
moveField: (label, target, up) => MoveItem(label, field, target.Location, up),
helpTopic: KnownHelpTopic,
showHelp: ShowDetailHelp);

// The row's override target, or null (with the reason logged) when it cannot be located.
// A failure disables the row's field commands rather than failing the whole menu.
private OverrideTarget LocateOverrideTarget(DetailField field)
{
try
{
return TryLocateOverrideTarget(field, out var templateId, out var location)
? new OverrideTarget(templateId, location)
: null;
}
catch (Exception e)
{
Logger.WriteError("Locating the row's override target failed; its Field Visibility and "
+ "Move Field commands are disabled.", e);
return null;
}
}

/// <summary>
/// The row's help topic when the help provider has it, else null. A failure hides Help
/// rather than failing the whole menu.
/// </summary>
internal string KnownHelpTopic(DetailField field)
{
try
{
var topic = ResolveHelpTopic(field);
if (topic == null)
return null;
var provider = m_propertyTable.GetValue<IHelpTopicProvider>("HelpTopicProvider");
return provider?.GetHelpString(topic) != null ? topic : null;
}
catch (Exception e)
{
Logger.WriteError("Resolving the row's help topic failed; Help is hidden.", e);
return null;
}
}

private void ShowDetailHelp(string topic)
=> ShowHelp.ShowHelpTopic(m_propertyTable.GetValue<IHelpTopicProvider>("HelpTopicProvider"), topic);

/// <summary>
/// Locates the row's node in its own compiled model, with the current override applied,
/// so visibility checkmarks and move enablement reflect the live state. False without a
Expand Down
Loading
Loading