Skip to content
Draft
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
44 changes: 44 additions & 0 deletions Src/xWorks/Avalonia/Hosting/OverrideCommandRegistry.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// 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;
using XCore;

namespace SIL.FieldWorks.XWorks
{
/// <summary>
/// The commands the Avalonia detail view retargets away from mediator dispatch, as ordered
/// (matcher, builder) entries. <see cref="TryBuild"/> builds the replacement item from the
/// first matching entry; null leaves the command on its normal dispatch.
/// </summary>
internal sealed class OverrideCommandRegistry
{
private readonly List<KeyValuePair<Func<ChoiceBase, bool>,
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem>>> _entries
= new List<KeyValuePair<Func<ChoiceBase, bool>,
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem>>>();

public void Add(string helpId, Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> build)
=> Add(c => string.Equals(c.HelpId, helpId, StringComparison.Ordinal), build);

/// <summary>Registers by matcher, for items that carry no command id.</summary>
public void Add(Func<ChoiceBase, bool> matches,
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> build)
=> _entries.Add(new KeyValuePair<Func<ChoiceBase, bool>,
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem>>(matches, build));

public DetailMenuItem TryBuild(ChoiceBase choice, UIItemDisplayProperties display)
{
foreach (var entry in _entries)
{
if (entry.Key(choice))
return entry.Value(choice, display);
}

return null;
}
}
}
178 changes: 146 additions & 32 deletions Src/xWorks/Avalonia/Hosting/RecordEditView.Avalonia.cs
Original file line number Diff line number Diff line change
Expand Up @@ -563,21 +563,28 @@ private ViewDefinitionOverride ResolveViewOverride(string className, string layo
+ "'; using the shipped definition.", error));

/// <summary>
/// Builds the interceptor that retargets the per-field Field Visibility and
/// Move Field commands to the project override layer for the Avalonia detail view. Returns null
/// (intercept nothing -- every command keeps its normal mediator dispatch) when the
/// clicked row
/// carries no (class, layout) context, e.g. the first-slice fallback rows; that keeps the legacy
/// behavior intact when the override layer cannot be addressed.
/// Builds the interceptor that retargets the per-field Field Visibility, Move Field, and
/// writing-system commands to the project override layer for the Avalonia detail view.
/// Returns null (intercept nothing -- every command keeps its normal mediator dispatch)
/// when the clicked row carries no (class, layout) context, e.g. the first-slice fallback
/// rows; that keeps the legacy behavior intact when the override layer cannot be
/// addressed.
/// </summary>
private Func<ChoiceBase, DetailMenuItem> BuildOverrideCommandInterceptor(DetailField field)
private Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> BuildOverrideCommandInterceptor(
DetailField field)
{
if (field == null || string.IsNullOrEmpty(field.ClassName) || string.IsNullOrEmpty(field.LayoutName)
|| ViewOverrideStore == null)
{
return null;
}

// Writing-system items dispatch normally; the resulting selection is then copied
// into the override. They need no located template node, so they stay registered
// even when locating fails.
var registry = new OverrideCommandRegistry();
registry.Add(IsWritingSystemVisibilityChoice, (c, d) => WritingSystemItem(c, d, field));

var templateId = ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId);
// Locate the clicked node in the field's OWN compiled model (with any current override
// already applied), so visibility checkmarks and move enablement reflect the live state.
Expand All @@ -596,54 +603,161 @@ private Func<ChoiceBase, DetailMenuItem> BuildOverrideCommandInterceptor(DetailF
{
Logger.WriteError("Resolving the field's override target failed; the gear-menu field "
+ "commands fall back to the legacy path for this row.", e);
return null;
return registry.TryBuild;
}

if (location == null)
return null; // unknown/stale target: leave commands on the legacy path rather than guess.

return choice =>
// Unknown/stale target: leave the field commands on the legacy path rather than
// guess.
if (location != null)
{
switch (choice.HelpId)
{
case "CmdAlwaysVisible":
return VisibilityItem(choice, field, templateId, location, ViewVisibility.Always);
case "CmdIfData":
return VisibilityItem(choice, field, templateId, location, ViewVisibility.IfData);
case "CmdNormallyHidden":
return VisibilityItem(choice, field, templateId, location, ViewVisibility.Never);
case "CmdDataTree-MoveFieldUp":
return MoveItem(choice, field, location, up: true);
case "CmdDataTree-MoveFieldDown":
return MoveItem(choice, field, location, up: false);
default:
return null; // not a field command: keep its normal mediator dispatch.
}
};
registry.Add("CmdAlwaysVisible",
(c, d) => VisibilityItem(d, field, templateId, location, ViewVisibility.Always));
registry.Add("CmdIfData",
(c, d) => VisibilityItem(d, field, templateId, location, ViewVisibility.IfData));
registry.Add("CmdNormallyHidden",
(c, d) => VisibilityItem(d, field, templateId, location, ViewVisibility.Never));
registry.Add("CmdDataTree-MoveFieldUp",
(c, d) => MoveItem(d, field, location, up: true));
registry.Add("CmdDataTree-MoveFieldDown",
(c, d) => MoveItem(d, field, location, up: false));
}

return registry.TryBuild;
}

// A Field Visibility menu item: checked when it is the field's current visibility, executes the
// SetVisibility override mutation (idempotent -- re-choosing the current value is a
// harmless write).
private DetailMenuItem VisibilityItem(ChoiceBase choice, DetailField field,
private DetailMenuItem VisibilityItem(UIItemDisplayProperties display, DetailField field,
string templateId, ViewNodeLocation location, ViewVisibility target)
{
var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text);
var label = XCoreMenuBridge.StripAccelerator(display.Text);
var isChecked = location.Visibility == target;
return new DetailMenuItem(label, isEnabled: true, isChecked: isChecked, children: null,
execute: () => ApplyFieldVisibility(field, templateId, target));
}

// A Move Field item: disabled at the first sibling (up) / last sibling (down) / when alone.
private DetailMenuItem MoveItem(ChoiceBase choice, DetailField field,
private DetailMenuItem MoveItem(UIItemDisplayProperties display, DetailField field,
ViewNodeLocation location, bool up)
{
var label = XCoreMenuBridge.StripAccelerator(choice.GetDisplayProperties().Text);
var label = XCoreMenuBridge.StripAccelerator(display.Text);
var canMove = up ? location.CanMoveUp : location.CanMoveDown;
return new DetailMenuItem(label, isEnabled: canMove, isChecked: false, children: null,
execute: canMove ? (Action)(() => ApplyMoveField(field, location, up)) : null);
}

/// <summary>
/// Whether this menu item makes a persistent change to which writing systems a
/// multi-writing-system field shows: a per-writing-system toggle (recognized by the
/// property its group drives -- the toggles carry no command id) or the Configure
/// dialog. Show all right now is excluded: it is a transient reveal on the slice,
/// not a configuration change, so persisting it would wrongly pin the full set.
/// </summary>
private static bool IsWritingSystemVisibilityChoice(ChoiceBase choice)
{
if (choice is ListPropertyChoice list)
{
return string.Equals(list.ParentProperty,
PropertyConstants.CurrentContextMenuSelectedWsIds, StringComparison.Ordinal);
}

return string.Equals(choice.HelpId, "CmdDataTree-WritingSystemMenu-Configure",
StringComparison.Ordinal);
}

/// <summary>
/// A writing-system item that dispatches normally and then copies the resulting
/// selection into the override: the hidden adapter slice owns the picker and the
/// Configure dialog, while the Avalonia detail view composes from its own override
/// store.
/// </summary>
private DetailMenuItem WritingSystemItem(ChoiceBase choice, UIItemDisplayProperties display,
DetailField field)
{
var isListToggle = choice is ListPropertyChoice;
// The bridge strips execute from disabled items, so the last checked toggle
// (disabled) can never be invoked to EMPTY the set.
return new DetailMenuItem(XCoreMenuBridge.StripAccelerator(display.Text), display.Enabled,
display.Checked, children: null, execute: () =>
{
// Snapshot first, so a dialog that changes nothing (e.g. Cancel) copies
// nothing.
var before = isListToggle ? null : CurrentSliceSelectedWritingSystems();
choice.OnClick(null, EventArgs.Empty);
CopyWritingSystemSelectionToOverride(field, isListToggle, before);
});
}

// Copies the click's selection into the row's override and recomposes. A toggle
// updates its property BEFORE the slice: read the property, in option order;
// Configure reads the slice.
private void CopyWritingSystemSelectionToOverride(DetailField field, bool fromListToggle,
IReadOnlyList<string> sliceSetBeforeClick)
{
try
{
var slice = m_dataEntryForm?.CurrentSlice as MultiStringSlice;
if (slice == null)
{
// The command dispatched, but the result is unreadable: say so, or the
// symptom is "the menu did nothing" with no trail.
Logger.WriteEvent("Writing-system selection was not copied: the adapter "
+ "slice is unreadable; the view override was not updated.");
return;
}

// A stale adapter target would store another row's set under this row's id.
if (slice.Object == null || slice.Object.Hvo != field.ObjectHvo)
{
Logger.WriteEvent("Writing-system selection was not copied: the adapter "
+ "slice is not the clicked row's; the view override was not updated.");
return;
}

List<string> selected;
if (fromListToggle)
{
var ids = m_propertyTable.GetStringProperty(
PropertyConstants.CurrentContextMenuSelectedWsIds, null);
// Canonicalize: option order, junk tokens dropped -- the stored order is the
// render order.
selected = string.IsNullOrEmpty(ids)
? null
: StringSliceUtils.GetVisibleWritingSystems(ids,
slice.WritingSystemOptionsForDisplay).Select(ws => ws.Id).ToList();
}
else
{
selected = slice.WritingSystemsSelectedForDisplay?.Select(ws => ws.Id).ToList();
if (selected != null && sliceSetBeforeClick != null
&& selected.SequenceEqual(sliceSetBeforeClick, StringComparer.Ordinal))
{
return; // the dialog changed nothing (e.g. Cancel): no override write.
}
}

// The menu disables the last checked toggle, so an empty set only means "nothing
// to copy" -- and an empty op would CLEAR the restriction, so bail instead.
if (selected == null || selected.Count == 0)
return;

var op = new ViewOverrideOperation(ViewOverrideOperationKind.SetVisibleWritingSystems,
ViewDefinitionOverrideEditor.StripRuntimeSuffix(field.StableId),
writingSystems: selected);
MutateOverrideAndRefresh(field, op);
}
catch (Exception e)
{
Logger.WriteError("Copying the writing-system selection into the view override failed.", e);
}
}

// The adapter slice's current selection, or null when it cannot be read.
private IReadOnlyList<string> CurrentSliceSelectedWritingSystems()
=> (m_dataEntryForm?.CurrentSlice as MultiStringSlice)
?.WritingSystemsSelectedForDisplay?.Select(ws => ws.Id).ToList();

// Writes a SetVisibility op for the field's template id into the project override and recomposes.
private void ApplyFieldVisibility(DetailField field, string templateId, ViewVisibility target)
{
Expand Down
27 changes: 19 additions & 8 deletions Src/xWorks/Avalonia/Hosting/XCoreMenuBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,16 +32,18 @@ public static IReadOnlyList<DetailMenuItem> CreateMenuItems(XWindow window, stri
/// <summary>
/// As <see cref="CreateMenuItems(XWindow, string[])"/>, but lets the host RETARGET specific leaf
/// commands for the Avalonia detail view (advanced-entry-view). For each command leaf, the
/// <paramref name="interceptor"/> is offered the leaf <see cref="ChoiceBase"/> (so the host can
/// read the localized label and command id from it); if it returns a non-null
/// <paramref name="interceptor"/> is offered the leaf <see cref="ChoiceBase"/> and its
/// already-computed display properties (so the host reads the localized label and state
/// without a second Display* round trip); if it returns a non-null
/// <see cref="DetailMenuItem"/>, that item (its label/checked/enabled/execute) is used INSTEAD of
/// the default xCore-dispatched item. Returning null leaves the command on its normal mediator
/// path. This is how the per-field Field Visibility / Move Field commands route to the project
/// override layer while Help and every other item keep working unchanged. The interceptor only
/// sees leaf commands (submenus pass through).
/// sees leaf commands (submenus pass through). Every leaf, default or retargeted, is
/// normalized so a disabled item carries no execute action.
/// </summary>
public static IReadOnlyList<DetailMenuItem> CreateMenuItems(XWindow window, string[] menuIds,
Func<ChoiceBase, DetailMenuItem> interceptor)
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> interceptor)
{
var group = window?.GetContextMenuChoiceGroup(menuIds);
if (group == null)
Expand All @@ -50,7 +52,8 @@ public static IReadOnlyList<DetailMenuItem> CreateMenuItems(XWindow window, stri
return Convert(group, interceptor);
}

private static List<DetailMenuItem> Convert(ChoiceGroup group, Func<ChoiceBase, DetailMenuItem> interceptor)
private static List<DetailMenuItem> Convert(ChoiceGroup group,
Func<ChoiceBase, UIItemDisplayProperties, DetailMenuItem> interceptor)
{
var items = new List<DetailMenuItem>();
foreach (var member in group)
Expand Down Expand Up @@ -90,23 +93,31 @@ private static List<DetailMenuItem> Convert(ChoiceGroup group, Func<ChoiceBase,
// advanced-entry-view: offer the leaf to the host; a non-null result retargets this
// command to the override layer (Field Visibility / Move Field) instead of the
// hidden-DataTree mediator dispatch.
var retargeted = interceptor?.Invoke(choice);
var retargeted = interceptor?.Invoke(choice, display);
if (retargeted != null)
{
items.Add(retargeted);
items.Add(WithoutExecuteWhenDisabled(retargeted));
continue;
}

var captured = choice;
items.Add(new DetailMenuItem(StripAccelerator(display.Text), display.Enabled,
display.Checked, null, () => captured.OnClick(null, EventArgs.Empty)));
display.Checked, null,
display.Enabled ? (Action)(() => captured.OnClick(null, EventArgs.Empty)) : null));
}
}

TrimSeparators(items);
return items;
}

// A disabled leaf carries no execute action, so "Execute != null" means invokable for
// every consumer -- programmatic invokers included, not just the pointer UI.
private static DetailMenuItem WithoutExecuteWhenDisabled(DetailMenuItem item)
=> item.IsEnabled || item.Execute == null
? item
: new DetailMenuItem(item.Label, isEnabled: false, item.IsChecked, item.Children, null);

// xCore marks the accelerator with a single '_' before the mnemonic; WinForms
// translates it to '&'. Avalonia shows text raw, so strip only the first
// marker: any later underscore is literal content.
Expand Down
Loading
Loading