From a712bb0f2d2ce3e0509bc32a98638a027ce652f0 Mon Sep 17 00:00:00 2001 From: mark-sil <83427558+mark-sil@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:13:28 -0400 Subject: [PATCH 1/3] LT-22691: Answer the environment menus natively The item menu of an environment and the two label menus that carry only the environment inserts are now answered from the row alone, so they do not need the hidden DataTree adapter any more. The five inserts type into the row's current editor at its caret and commit when the item is left, as the WinForms view types into its scratch cache; editing the model per insert would mint an environment for every intermediate state. Describe Error reports the domain's own explanation, which is the text the WinForms view shows. The enablement rules move to FdoUi/DetailRules and the two WinForms views call them there. A menu request now snapshots the editor's text and selection before the menu can take focus, and the editor holds its commit while the menu or the natural-class chooser has focus. A label gesture focuses its row as a WinForms tree-node click does, without taking focus from the row's own editor, so the menu closes back onto that row. A right press on an item focuses it and places the caret under the pointer, keeping a selection the pointer is inside. With no current editor the inserts are disabled, where the WinForms view offers a slash that acts on its empty last line; the typed slot covers that case once the caret is in it, and a right-click on the slot opens the row's label menu so its inserts can type there. Co-Authored-By: Claude Fable 5.1 --- .../DetailControls/PhoneEnvReferenceView.cs | 27 +- .../XMLViews/ReallySimpleListChooser.cs | 43 ++- Src/Common/FwAvalonia/Detail/DataTree.cs | 65 +++- .../FwAvalonia/Detail/DetailMenuFlyout.cs | 4 + Src/Common/FwAvalonia/Detail/DetailModel.cs | 95 +++++ .../FwAvalonia/Detail/FwFieldControls.cs | 168 ++++++++- .../Detail/RetypableVectorItemTests.cs | 292 +++++++++++++++ .../FwAvaloniaTests/DetailMenuTests.cs | 104 ++++++ .../DetailRules/EnvironmentInsertRules.cs | 82 +++++ .../EnvironmentInsertRulesTests.cs | 78 ++++ .../Morphology/PhEnvStrRepresentationSlice.cs | 40 +-- .../Hosting/EnvironmentInsertMenuAuthority.cs | 65 ++++ .../Avalonia/Hosting/EnvironmentMenuLeaves.cs | 139 ++++++++ .../Avalonia/Hosting/ObjectMenuAuthority.cs | 9 +- .../Hosting/RecordEditView.Avalonia.cs | 17 +- .../RecordEditView.ReferenceVectorMenus.cs | 141 +++----- .../Hosting/ReferenceItemMenuAuthority.cs | 40 ++- Src/xWorks/xWorksStrings.Designer.cs | 9 + Src/xWorks/xWorksStrings.resx | 4 + .../DetailObjectCommandExecutionTests.cs | 333 ++++++++++++++---- .../Hosting/EnvironmentMenuLeavesTests.cs | 138 ++++++++ .../Avalonia/Hosting/TextEditorStub.cs | 43 +++ 22 files changed, 1681 insertions(+), 255 deletions(-) create mode 100644 Src/FdoUi/DetailRules/EnvironmentInsertRules.cs create mode 100644 Src/FdoUi/FdoUiTests/DetailRules/EnvironmentInsertRulesTests.cs create mode 100644 Src/xWorks/Avalonia/Hosting/EnvironmentInsertMenuAuthority.cs create mode 100644 Src/xWorks/Avalonia/Hosting/EnvironmentMenuLeaves.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/EnvironmentMenuLeavesTests.cs create mode 100644 Src/xWorks/xWorksTests/Avalonia/Hosting/TextEditorStub.cs diff --git a/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs b/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs index 37bafb566e..9063f7e248 100644 --- a/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs +++ b/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs @@ -12,6 +12,7 @@ using SIL.LCModel.Core.Cellar; using SIL.LCModel.Core.Phonology; using SIL.LCModel.Core.Text; +using SIL.FieldWorks.Common.DetailRules; using SIL.FieldWorks.Common.ViewsInterfaces; using SIL.FieldWorks.Common.Framework.DetailControls.Resources; using SIL.LCModel.Core.KernelInterfaces; @@ -995,6 +996,9 @@ private bool CanGetEnvironmentStringRep(out string s) return true; } + // The insert rules live in EnvironmentInsertRules, shared with the Avalonia row; this + // view supplies the selected environment's text and selection. + internal bool CanInsertSlash() { CheckDisposed(); @@ -1005,12 +1009,10 @@ internal bool CanInsertSlash() IVwSelection vwsel; if (!GetSelectedStringRep(out tss, out vwsel, out hvoDummyObj, out ichAnchor, out ichEnd)) return false; + // A selection off every environment (the empty line at the end) starts a new one. if (tss == null || hvoDummyObj == 0) return true; - string s = tss.Text; - if (s == null || s == String.Empty) - return true; - return s.IndexOf('/') < 0; + return EnvironmentInsertRules.CanInsertSlash(tss.Text); } internal bool CanInsertEnvBar() @@ -1025,12 +1027,7 @@ internal bool CanInsertEnvBar() return false; if (tss == null || hvoDummyObj == 0) return false; - string s = tss.Text; - if (s == null || s == String.Empty) - return false; - int ichSlash = s.IndexOf('/'); - return (ichSlash >= 0) && (ichEnd > ichSlash) && (ichAnchor > ichSlash) && - (s.IndexOf('_') < 0); + return EnvironmentInsertRules.CanInsertBar(tss.Text, ichAnchor, ichEnd); } internal bool CanInsertItem() @@ -1045,10 +1042,7 @@ internal bool CanInsertItem() return false; if (tss == null || hvoDummyObj == 0) return false; - string s = tss.Text; - if (s == null || s == String.Empty) - return false; - return PhonEnvRecognizer.CanInsertItem(s, ichEnd, ichAnchor); + return EnvironmentInsertRules.CanInsertItem(tss.Text, ichAnchor, ichEnd); } internal bool CanInsertHashMark() @@ -1063,10 +1057,7 @@ internal bool CanInsertHashMark() return false; if (tss == null || hvoDummyObj == 0) return false; - string s = tss.Text; - if (s == null || s == String.Empty) - return false; - return PhonEnvRecognizer.CanInsertHashMark(s, ichEnd, ichAnchor); + return EnvironmentInsertRules.CanInsertHashMark(tss.Text, ichAnchor, ichEnd); } #endregion diff --git a/Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs b/Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs index 902b065997..285d476e54 100644 --- a/Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs +++ b/Src/Common/Controls/XMLViews/ReallySimpleListChooser.cs @@ -2596,6 +2596,30 @@ public void SetHelpTopic(string helpTopic) /// public static bool ChooseNaturalClass(IVwRootBox rootb, LcmCache cache, IPersistenceProvider persistenceProvider, Mediator mediator, PropertyTable propertyTable) + { + var pnc = ChooseNaturalClass(cache, persistenceProvider, mediator, propertyTable); + if (pnc != null) + { + ITsString tss = pnc.Abbreviation.BestAnalysisVernacularAlternative; + string sIns = String.Format("[{0}]", tss.Text); + int wsPending = cache.DefaultVernWs; + IVwRootSite site = rootb.Site; + IVwGraphics vg = null; + if (site != null) + vg = site.get_ScreenGraphics(rootb); + rootb.OnTyping(vg, sIns, VwShiftStatus.kfssNone, ref wsPending); + } + return true; + } + + /// + /// Bring up a chooser for selecting a natural class. The Avalonia environment menus + /// insert the chosen class into their own editor. + /// + /// The chosen class; null when the user cancelled or took the chooser's jump + /// link. + public static IPhNaturalClass ChooseNaturalClass(LcmCache cache, + IPersistenceProvider persistenceProvider, Mediator mediator, PropertyTable propertyTable) { IEnumerable labels = ObjectLabel.CreateObjectLabels(cache, cache.LanguageProject.PhonologicalDataOA.NaturalClassesOS, "", @@ -2627,24 +2651,11 @@ public static bool ChooseNaturalClass(IVwRootBox rootb, LcmCache cache, DialogResult res = chooser.ShowDialog(); if (DialogResult.Cancel == res) - return true; + return null; if (chooser.HandleAnyJump()) - return true; - if (chooser.ChosenOne != null) - { - var pnc = (IPhNaturalClass) chooser.ChosenOne.Object; - ITsString tss = pnc.Abbreviation.BestAnalysisVernacularAlternative; - string sName = tss.Text; - string sIns = String.Format("[{0}]", sName); - int wsPending = cache.DefaultVernWs; - IVwRootSite site = rootb.Site; - IVwGraphics vg = null; - if (site != null) - vg = site.get_ScreenGraphics(rootb); - rootb.OnTyping(vg, sIns, VwShiftStatus.kfssNone, ref wsPending); - } + return null; + return chooser.ChosenOne?.Object as IPhNaturalClass; } - return true; } /// diff --git a/Src/Common/FwAvalonia/Detail/DataTree.cs b/Src/Common/FwAvalonia/Detail/DataTree.cs index 4b4f6c1171..504f1f80b6 100644 --- a/Src/Common/FwAvalonia/Detail/DataTree.cs +++ b/Src/Common/FwAvalonia/Detail/DataTree.cs @@ -692,7 +692,7 @@ private FieldContent AddField(int row, DetailField field) // The field's slice menu opens from the label cell's right-click or the // gutter "..." button; the editor's current item rides each request it raises. var labelCell = WrapWithFieldMenu(labelBlock, field, automationId, out var labelKebab, - editor as IDetailItemSelection); + editor as IDetailItemSelection, editor); // Hover-reveal: the WHOLE row (label cell + editor) is the hover/focus // surface for the field-options "..." and any editor affordance (chooser @@ -738,7 +738,7 @@ private void OnVectorSelectionChanged(object sender, EventArgs e) // The label cell answers context-menu requests with the row's slice menu; the // kebab opens its own menu or hotlinks. With no host bridge, content is unwrapped. private Control WrapWithFieldMenu(Control inner, DetailField field, string automationId, - out Control kebab, IDetailItemSelection selection = null) + out Control kebab, IDetailItemSelection selection = null, Control editor = null) { kebab = null; if (_menuRequested == null) @@ -765,14 +765,36 @@ private Control WrapWithFieldMenu(Control inner, DetailField field, string autom AutomationProperties.SetName(button, FwAvaloniaStrings.FieldOptionsMenu); ToolTip.SetTip(button, FwAvaloniaStrings.FieldOptionsMenu); var kind = hasMenu ? DetailMenuKind.SliceMenu : DetailMenuKind.Hotlinks; - // Button.Click fires for both a mouse click and keyboard activation (Enter/Space), so the - // affordance is fully keyboard-operable once Tab focus reveals it. + // Click fires for keyboard activation (Enter/Space) once Tab focus reveals the + // button; a pointer click is answered by the rail handlers below instead. button.Click += (s, e) => { // No pointer position is available here, so the menu drops from the // icon rather than from wherever the mouse sits. _menuRequested(DetailMenuRequest.FromAnchor(button, field, kind, selection)); }; + // The press is swallowed before the button can take focus from the row's + // editor (as under SliceTreeNode's icon); the release raises the menu. + var pressedOnButton = false; + rail.AddHandler(Avalonia.Input.InputElement.PointerPressedEvent, (s, e) => + { + if (!IsOn(button, e.Source) || !e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) + return; + pressedOnButton = true; + e.Handled = true; + TakeRowFocus(rail, editor, button); + }, Avalonia.Interactivity.RoutingStrategies.Tunnel); + rail.AddHandler(Avalonia.Input.InputElement.PointerReleasedEvent, (s, e) => + { + if (!pressedOnButton) + return; + pressedOnButton = false; + e.Handled = true; + _menuRequested(DetailMenuRequest.FromAnchor(button, field, kind, selection)); + }, Avalonia.Interactivity.RoutingStrategies.Tunnel); + // A press whose release never arrives (capture lost mid-press) opens nothing. + rail.AddHandler(Avalonia.Input.InputElement.PointerCaptureLostEvent, + (s, e) => pressedOnButton = false, Avalonia.Interactivity.RoutingStrategies.Tunnel); rail.Child = button; kebab = button; } @@ -781,19 +803,48 @@ private Control WrapWithFieldMenu(Control inner, DetailField field, string autom DockPanel.SetDock(rail, Dock.Left); wrapper.Children.Add(rail); wrapper.Children.Add(inner); // fills the width remaining after the gutter - WireLabelContextMenu(wrapper, field, selection); + WireLabelContextMenu(wrapper, field, selection, editor, kebab); return wrapper; } + // Whether an event source is the control or something drawn inside it. + private static bool IsOn(Control control, object source) + => source is Visual visual + && (ReferenceEquals(visual, control) + || Avalonia.VisualTree.VisualExtensions.IsVisualAncestorOf(control, visual)); + + // A label gesture focuses its row (Slice.TakeFocus), so the menu closes back onto it; + // a vector row takes it on its button, since focusing an item would make it current. + private static void TakeRowFocus(Control cell, Control editor, Control kebab) + { + if (editor == null) + return; + var focused = TopLevel.GetTopLevel(cell)?.FocusManager?.GetFocusedElement(); + if (IsOn(cell, focused) || IsOn(editor, focused)) + return; + Control target; + if (editor is IDetailItemSelection) + target = kebab; + else if (editor.Focusable) + target = editor; + else + { + target = Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(editor).OfType() + .FirstOrDefault(c => c.Focusable && c.IsEffectivelyEnabled && c.IsEffectivelyVisible); + } + target?.Focus(); + } + /// /// Wires up the Label context menu for a slice; the request carries the row editor's - /// current item when it keeps one. + /// current item when it keeps one, and the row takes focus first. /// private void WireLabelContextMenu(Control cell, DetailField field, - IDetailItemSelection selection) + IDetailItemSelection selection, Control editor, Control kebab) { cell.AddHandler(Control.ContextRequestedEvent, (s, e) => { + TakeRowFocus(cell, editor, kebab); _menuRequested(DetailMenuRequest.FromContextRequested(cell, e, field, DetailMenuKind.SliceMenu, selection)); e.Handled = true; diff --git a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs index b0fa79ac87..baa45a0ed3 100644 --- a/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs +++ b/Src/Common/FwAvalonia/Detail/DetailMenuFlyout.cs @@ -35,6 +35,10 @@ private DetailMenuItem() public static DetailMenuItem Separator() => new DetailMenuItem(); + /// A leaf that is shown but cannot run. + public static DetailMenuItem Disabled(string label) + => new DetailMenuItem(label, isEnabled: false, isChecked: false, children: null, execute: null); + public string Label { get; } public bool IsEnabled { get; } public bool IsChecked { get; } diff --git a/Src/Common/FwAvalonia/Detail/DetailModel.cs b/Src/Common/FwAvalonia/Detail/DetailModel.cs index f6a02160aa..210d4f05cc 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1835,6 +1835,51 @@ public interface IDetailItemSelection int SelectedItemIndex { get; } } + /// + /// A field editor whose current text editor a row's menus act on: a retypable + /// reference-vector row's item editor or its typed slot for a new item. A menu request + /// snapshots the text and selection when it is raised, before the menu takes focus, and a + /// text command writes back through . + /// + public interface IDetailTextSelection + { + /// Whether a text editor is current; false for a row of read-only + /// items. + bool HasTextEditor { get; } + + /// The current editor's text; null without an editor. + string EditorText { get; } + + /// The current editor's selection anchor; -1 without an editor. + int EditorSelectionAnchor { get; } + + /// The current editor's selection end, which is the caret; -1 without an + /// editor. + int EditorSelectionEnd { get; } + + /// + /// Replaces the span .. + /// of the current editor's text with , leaving the caret + /// characters before the end of the inserted text; false + /// without an editor. The span is the one a request snapshotted, since the editor's + /// own selection collapses when the menu takes focus. The editor commits as it would + /// after typing. + /// + bool ReplaceEditorText(int selectionAnchor, int selectionEnd, string text, int caretBack); + + /// + /// Marks a menu gesture in progress on the editor: its commit-on-blur waits, since the + /// menu itself may take focus, until . + /// + void BeginMenuGesture(); + + /// + /// Ends the gesture begun by , returning focus to the + /// editor when the menu took it or a command wrote to it. + /// + void EndMenuGesture(); + } + /// Which configured menu a context-menu request maps to. public enum DetailMenuKind { @@ -1875,8 +1920,23 @@ private DetailMenuRequest(DetailField field, DetailMenuKind kind, Control anchor SelectedItemKey = selection?.SelectedItemKey; SelectedItemIndex = selection?.SelectedItemIndex ?? -1; IsDefaultActivation = isDefaultActivation; + // Snapshotted now, before the menu can take focus or move the selection. + if (selection is IDetailTextSelection text && text.HasTextEditor) + { + _textSelection = text; + EditorText = text.EditorText; + EditorSelectionAnchor = text.EditorSelectionAnchor; + EditorSelectionEnd = text.EditorSelectionEnd; + } + else + { + EditorSelectionAnchor = -1; + EditorSelectionEnd = -1; + } } + private readonly IDetailTextSelection _textSelection; + /// /// The request for an item's default activation (Ctrl+click on a reference-vector /// item): the host runs the item menu's first enabled jump command instead of showing @@ -1949,6 +2009,41 @@ public static DetailMenuRequest FromAnchor(Control anchor, DetailField field, /// rather than opening at the pointer. /// public bool OpenAtPointer { get; } + + /// + /// Whether the row had a text editor current when the menu was requested (a retypable + /// item's editor or the typed slot), which the menu's text commands act on. + /// + public bool HasTextEditor => _textSelection != null; + + /// The editor's text at request time; null without an editor. + public string EditorText { get; } + + /// The editor's selection anchor at request time; -1 without an + /// editor. + public int EditorSelectionAnchor { get; } + + /// The editor's selection end (the caret) at request time; -1 without an + /// editor. + public int EditorSelectionEnd { get; } + + /// + /// Replaces the selection the request snapshotted with , leaving + /// the caret characters before the end of it; false without + /// an editor. + /// + public bool ReplaceEditorSelection(string text, int caretBack) + => _textSelection != null + && _textSelection.ReplaceEditorText(EditorSelectionAnchor, EditorSelectionEnd, text, caretBack); + + /// + /// Tells the editor a menu gesture is in progress, so it does not commit when the menu + /// takes focus. Nothing happens without an editor. + /// + public void BeginMenuGesture() => _textSelection?.BeginMenuGesture(); + + /// Ends the gesture begun by . + public void EndMenuGesture() => _textSelection?.EndMenuGesture(); } /// diff --git a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs index 0827307430..c72f9d7bc7 100644 --- a/Src/Common/FwAvalonia/Detail/FwFieldControls.cs +++ b/Src/Common/FwAvalonia/Detail/FwFieldControls.cs @@ -1347,9 +1347,16 @@ private static Action FindSink(FlyoutBase flyout) /// false and is unaffected. /// public sealed class FwReferenceVectorField : WrapPanel, IHoverAffordanceProvider, - IDetailItemSelection, IDisposable + IDetailItemSelection, IDetailTextSelection, IDisposable { private readonly List _affordances = new List(); + // The editor the row's text commands act on: the current item's editor or the typed + // slot, whichever was last pressed or focused; null on a row of read-only items. + private TextBox _currentEditor; + // While a menu gesture is in progress the editors do not commit on losing focus, since + // the menu itself takes it; the gesture's end returns focus when that happened. + private bool _menuGestureActive; + private bool _refocusAfterGesture; // Teardown for the per-item select/Remove handlers, the add picker's subscriptions, the // gear click, and the option flyout, so a recycled vector cell releases every closure. private readonly List _teardown = new List(); @@ -1438,7 +1445,7 @@ public FwReferenceVectorField( gestureCompleted?.Invoke(); }; EventHandler commitOnBlur = - (s2, e2) => commitText(); + (s2, e2) => CommitUnlessMenuGesture(commitText); box.LostFocus += commitOnBlur; EventHandler commitOnEnter = (s2, e2) => { @@ -1474,11 +1481,25 @@ public FwReferenceVectorField( var itemIndex = index; text.Focusable = true; KeyboardNavigation.SetIsTabStop(text, retypable || index == 0); - EventHandler focusSelect = (s, e) => SelectItem(itemIndex); + EventHandler focusSelect = (s, e) => + { + SelectItem(itemIndex); + _currentEditor = text as TextBox; + }; text.GotFocus += focusSelect; EventHandler select = (s, e) => { SelectItem(itemIndex); + // A right press does not focus the editor by itself, so the press focuses it + // and puts the caret at the pointer, as a right-click in + // PhoneEnvReferenceView does. + _currentEditor = text as TextBox; + if (_currentEditor != null && e.GetCurrentPoint(text).Properties.IsRightButtonPressed) + { + if (!_currentEditor.IsFocused) + _currentEditor.Focus(); + PlaceCaretAtPointer(_currentEditor, e); + } // Ctrl+click runs the item menu's default jump without showing the menu. if (menuRequested != null && e.GetCurrentPoint(text).Properties.IsLeftButtonPressed @@ -1535,14 +1556,7 @@ public FwReferenceVectorField( { // The press above already made this the current item, so the request's // selected item is the one under the pointer. - EventHandler itemMenu = (s, e) => - { - menuRequested(DetailMenuRequest.FromContextRequested(text, e, field, - DetailMenuKind.ItemMenu, this)); - e.Handled = true; - }; - text.AddHandler(ContextRequestedEvent, itemMenu); - _teardown.Add(() => text.RemoveHandler(ContextRequestedEvent, itemMenu)); + WireBridgedMenu(text, DetailMenuKind.ItemMenu, field, menuRequested); } else if (editable) { @@ -1655,7 +1669,7 @@ public FwReferenceVectorField( gestureCompleted?.Invoke(); }; EventHandler newOnBlur = - (s2, e2) => commitNew(); + (s2, e2) => CommitUnlessMenuGesture(commitNew); newItem.LostFocus += newOnBlur; EventHandler newOnEnter = (s2, e2) => { @@ -1665,9 +1679,13 @@ public FwReferenceVectorField( commitNew(); }; newItem.KeyDown += newOnEnter; - // This slot names no item, so it must not leave a stale one current: a menu - // request from here would otherwise act on whichever item was clicked before. - EventHandler newClearsSelection = (s2, e2) => ClearSelection(); + // The slot names no item, so none stays current; it is still the editor the + // menu's text commands act on, like PhoneEnvReferenceView's empty last line. + EventHandler newClearsSelection = (s2, e2) => + { + ClearSelection(); + _currentEditor = newItem; + }; newItem.GotFocus += newClearsSelection; _teardown.Add(() => { @@ -1675,6 +1693,12 @@ public FwReferenceVectorField( newItem.KeyDown -= newOnEnter; newItem.GotFocus -= newClearsSelection; }); + if (menuRequested != null) + { + // A right-click on the slot opens the row's label menu, whose inserts type + // into it, like PhoneEnvReferenceView's empty last line. + WireBridgedMenu(newItem, DetailMenuKind.SliceMenu, field, menuRequested); + } Children.Add(newItem); AddSeparatorBar(); } @@ -1792,6 +1816,120 @@ public static bool IsItemAutomationId(string rowAutomationId, string automationI /// The index of the selected item in the field's Items; -1 when none. public int SelectedItemIndex => _selectedIndex; + /// Whether an item editor or the typed slot is current. + public bool HasTextEditor => !_disposed && _currentEditor != null; + + /// The current editor's text; null without an editor. + public string EditorText => HasTextEditor ? _currentEditor.Text ?? string.Empty : null; + + /// The current editor's selection anchor; -1 without an editor. + public int EditorSelectionAnchor => HasTextEditor ? _currentEditor.SelectionStart : -1; + + /// The current editor's selection end, the caret; -1 without an + /// editor. + public int EditorSelectionEnd => HasTextEditor ? _currentEditor.SelectionEnd : -1; + + /// + /// Replaces the span .. + /// of the current editor's text with and leaves the caret + /// characters before the end of it. The editor commits + /// later, as it does after typing; false without an editor. + /// + public bool ReplaceEditorText(int selectionAnchor, int selectionEnd, string text, int caretBack) + { + if (!HasTextEditor || text == null) + return false; + var box = _currentEditor; + var current = box.Text ?? string.Empty; + var start = Math.Max(0, Math.Min(Math.Min(selectionAnchor, selectionEnd), current.Length)); + var end = Math.Max(start, Math.Min(Math.Max(selectionAnchor, selectionEnd), current.Length)); + box.Text = current.Substring(0, start) + text + current.Substring(end); + var caret = Math.Max(start, start + text.Length - caretBack); + // The CaretIndex setter collapses the selection; the ends are then set to match. + box.CaretIndex = caret; + box.SelectionStart = caret; + box.SelectionEnd = caret; + // The editor now holds pending text, so focus returns to it: at the gesture's end, + // or now when the menu has already closed, as it does under a modal chooser. + if (_menuGestureActive) + _refocusAfterGesture = true; + else if (!box.IsFocused) + box.Focus(); + return true; + } + + /// Holds the editors' commit-on-blur while a menu gesture is in + /// progress. + public void BeginMenuGesture() + { + _menuGestureActive = true; + _refocusAfterGesture = HasTextEditor && _currentEditor.IsFocused; + } + + /// + /// Ends the menu gesture; focus returns to the editor when the menu took it, it held + /// focus when the gesture began, or a command wrote to it. That holds for a menu + /// dismissed by a click elsewhere too: the dismissing click reaches nothing, as under a + /// WinForms context menu, so the editor is still where the user was. + /// + public void EndMenuGesture() + { + if (!_menuGestureActive) + return; + _menuGestureActive = false; + var refocus = _refocusAfterGesture; + _refocusAfterGesture = false; + if (refocus && HasTextEditor) + _currentEditor.Focus(); + } + + // TextBox applies this rule on the right RELEASE, after the request has snapshotted the + // selection, so it is applied on the press; a press inside a range keeps the range. + private static void PlaceCaretAtPointer(TextBox box, PointerPressedEventArgs e) + { + var presenter = Avalonia.VisualTree.VisualExtensions.GetVisualDescendants(box) + .OfType().FirstOrDefault(); + if (presenter == null) + return; + var anchor = box.SelectionStart; + var end = box.SelectionEnd; + presenter.MoveCaretToPoint(e.GetPosition(presenter)); + var hit = presenter.CaretIndex; + var insideRange = anchor != end && hit >= Math.Min(anchor, end) && hit <= Math.Max(anchor, end); + var newAnchor = insideRange ? anchor : hit; + var newEnd = insideRange ? end : hit; + box.CaretIndex = newEnd; + box.SelectionStart = newAnchor; + box.SelectionEnd = newEnd; + } + + // Exactly one menu on an editor: the theme's Cut/Copy/Paste flyout is dropped, as the + // in-string editors drop it, and a right-click raises the host's menu for the row. + private void WireBridgedMenu(Control editor, DetailMenuKind kind, DetailField field, + Action menuRequested) + { + editor.ContextFlyout = null; + EventHandler bridged = (s, e) => + { + menuRequested(DetailMenuRequest.FromContextRequested(editor, e, field, kind, this)); + e.Handled = true; + }; + editor.AddHandler(ContextRequestedEvent, bridged); + _teardown.Add(() => editor.RemoveHandler(ContextRequestedEvent, bridged)); + } + + // An editor's blur during a menu gesture is the menu taking focus, not the user leaving + // the editor, so the commit waits and the gesture's end brings focus back. + private void CommitUnlessMenuGesture(Action commit) + { + if (_menuGestureActive) + { + _refocusAfterGesture = true; + return; + } + commit(); + } + /// Makes the item with this option key current; false when no item has /// it. public bool SelectItem(string key) diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs index a86dc04a8e..09a1292f56 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs @@ -526,5 +526,297 @@ public void AStagedEdit_CompletesTheGesture_AndARefusedOneDoesNot() "a refused edit completes no gesture, so the row is not re-shown over an edit " + "the domain did not take"); } + + // ===== The text editor the row's menus act on ===== + + private static DetailMenuRequest LabelMenuRequest(FwReferenceVectorField row) + => DetailMenuRequest.FromAnchor(null, Row(), DetailMenuKind.SliceMenu, row); + + [AvaloniaTest] + public void ARowOfReadOnlyItems_HasNoTextEditor() + { + var (row, _) = Show(new FakeTextEditing { Retypable = false }); + row.SelectItem("e1"); + + Assert.That(row.HasTextEditor, Is.False); + var request = LabelMenuRequest(row); + Assert.That(request.HasTextEditor, Is.False); + Assert.That(request.EditorSelectionEnd, Is.EqualTo(-1)); + Assert.That(request.ReplaceEditorSelection("/", 0), Is.False, "nothing to type into"); + } + + [AvaloniaTest] + public void AMenuRequest_SnapshotsTheFocusedEditorsTextAndSelection() + { + var (row, _) = Show(new FakeTextEditing()); + var editor = Editor(row, "e2"); + editor.Focus(); + editor.CaretIndex = 1; + Dispatcher.UIThread.RunJobs(); + + var request = LabelMenuRequest(row); + + Assert.That(request.SelectedItemKey, Is.EqualTo("e2"), "focus made the item current"); + Assert.That(request.HasTextEditor, Is.True); + Assert.That(request.EditorText, Is.EqualTo("/_a")); + Assert.That(request.EditorSelectionAnchor, Is.EqualTo(1)); + Assert.That(request.EditorSelectionEnd, Is.EqualTo(1)); + } + + /// + /// A right press does not focus an editor, so the press itself must make the item under + /// the pointer the one the item menu's inserts type into. + /// + [AvaloniaTest] + public void RightClickingAnItemEditor_MakesItTheEditorTheMenuActsOn() + { + var requests = new List(); + var row = new FwReferenceVectorField(Row(), "PhoneEnv", new FakeTextEditing(), null, null, + r => requests.Add(r)); + var window = new Window { Content = row, Width = 480, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + var item = Editor(row, "e1"); + // Just inside the right edge of the text, past the last character. + var nearTheEnd = new Avalonia.Point(item.Bounds.X + item.Bounds.Width - 2, + item.Bounds.Y + item.Bounds.Height / 2); + + window.MouseDown(nearTheEnd, MouseButton.Right); + window.MouseUp(nearTheEnd, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + + Assert.That(requests, Has.Count.EqualTo(1)); + Assert.That(requests[0].HasTextEditor, Is.True); + Assert.That(requests[0].EditorText, Is.EqualTo("/_#")); + Assert.That(item.IsFocused, Is.True, + "the right press focused the editor, so the menu's gesture returns focus to it"); + Assert.That(item.CaretIndex, Is.EqualTo(3), + "the right press put the caret under the pointer, as PhoneEnvReferenceView does"); + Assert.That(requests[0].EditorSelectionAnchor, Is.EqualTo(3), + "the snapshot is the editor's selection at request time"); + Assert.That(requests[0].EditorSelectionEnd, Is.EqualTo(3)); + } + + /// + /// A right press inside a range selection keeps the range, as SimpleRootSite does, so + /// the item menu's inserts replace what the user selected. + /// + [AvaloniaTest] + public void RightClickingInsideASelection_KeepsIt_ForTheMenu() + { + var requests = new List(); + var row = new FwReferenceVectorField(Row(), "PhoneEnv", new FakeTextEditing(), null, null, + r => requests.Add(r)); + var window = new Window { Content = row, Width = 480, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + var item = Editor(row, "e1"); + item.Focus(); + item.SelectionStart = 0; + item.SelectionEnd = 3; // the whole of "/_#" + Dispatcher.UIThread.RunJobs(); + var nearTheEnd = new Avalonia.Point(item.Bounds.X + item.Bounds.Width - 2, + item.Bounds.Y + item.Bounds.Height / 2); + + window.MouseDown(nearTheEnd, MouseButton.Right); + window.MouseUp(nearTheEnd, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + + Assert.That(requests, Has.Count.EqualTo(1)); + Assert.That(requests[0].EditorSelectionAnchor, Is.EqualTo(0), "the press landed inside the range"); + Assert.That(requests[0].EditorSelectionEnd, Is.EqualTo(3)); + Assert.That(item.ContextFlyout, Is.Null, + "the theme's Cut/Copy/Paste flyout is gone, so only the bridged menu can show"); + } + + /// + /// The slot is where a new environment is typed, as the empty last line of + /// PhoneEnvReferenceView is, so with the caret there a label menu's inserts type into it + /// while no item is current. + /// + [AvaloniaTest] + public void TheSlot_IsTheEditorTheLabelMenuActsOn_WhileItHasTheCaret() + { + var (row, _) = Show(new FakeTextEditing { Creatable = true }); + row.SelectItem("e1"); + var slot = NewItemSlot(row); + slot.Focus(); + Dispatcher.UIThread.RunJobs(); + + var request = LabelMenuRequest(row); + + Assert.That(request.SelectedItemKey, Is.Null, "no item is current"); + Assert.That(request.HasTextEditor, Is.True, "but the slot is the editor"); + Assert.That(request.EditorText, Is.EqualTo(string.Empty)); + Assert.That(request.EditorSelectionEnd, Is.EqualTo(0)); + } + + /// + /// The slot has no item of its own, so a right-click on it raises the row's label menu + /// with the slot as the editor, and nothing commits the typed text on the way. + /// + [AvaloniaTest] + public void RightClickingTheSlot_RaisesTheLabelMenu_WithTheSlotAsEditor_AndCommitsNothing() + { + var requests = new List(); + var context = new FakeTextEditing { Creatable = true }; + var row = new FwReferenceVectorField(Row(), "PhoneEnv", context, null, null, r => requests.Add(r)); + var window = new Window { Content = row, Width = 480, Height = 200 }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + window.UpdateLayout(); + Dispatcher.UIThread.RunJobs(); + var slot = NewItemSlot(row); + slot.Focus(); + slot.Text = "/#f"; + slot.CaretIndex = 3; + Dispatcher.UIThread.RunJobs(); + var centre = new Avalonia.Point(slot.Bounds.X + slot.Bounds.Width / 2, + slot.Bounds.Y + slot.Bounds.Height / 2); + + window.MouseDown(centre, MouseButton.Right); + window.MouseUp(centre, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + + Assert.That(slot.ContextFlyout, Is.Null, "no theme flyout to take focus from the slot"); + Assert.That(requests, Has.Count.EqualTo(1)); + Assert.That(requests[0].Kind, Is.EqualTo(DetailMenuKind.SliceMenu), "the row's label menu"); + Assert.That(requests[0].SelectedItemKey, Is.Null, "no item is current"); + Assert.That(requests[0].HasTextEditor, Is.True); + Assert.That(requests[0].EditorText, Is.EqualTo("/#f")); + Assert.That(context.Created, Is.Empty, "the typed text is still pending"); + } + + [AvaloniaTest] + public void ReplaceEditorSelection_TypesAtTheCaret_AndStagesNothingYet() + { + var context = new FakeTextEditing(); + var (row, _) = Show(context); + var editor = Editor(row, "e1"); + editor.Focus(); + editor.CaretIndex = 1; + Dispatcher.UIThread.RunJobs(); + var request = LabelMenuRequest(row); + + Assert.That(request.ReplaceEditorSelection("()", caretBack: 1), Is.True); + + Assert.That(editor.Text, Is.EqualTo("/()_#")); + Assert.That(editor.CaretIndex, Is.EqualTo(2), "the caret sits between the parentheses"); + Assert.That(editor.SelectionStart, Is.EqualTo(editor.SelectionEnd), "nothing is selected"); + Assert.That(context.Edits, Is.Empty, "typing through the menu commits as typing does: later"); + Assert.That(row.HasUnstagedText, Is.True); + } + + [AvaloniaTest] + public void ReplaceEditorSelection_ReplacesASelectedSpan() + { + var (row, _) = Show(new FakeTextEditing()); + var editor = Editor(row, "e1"); + editor.Focus(); + editor.SelectionStart = 2; + editor.SelectionEnd = 3; + Dispatcher.UIThread.RunJobs(); + + LabelMenuRequest(row).ReplaceEditorSelection("_", 0); + + Assert.That(editor.Text, Is.EqualTo("/__")); + Assert.That(editor.CaretIndex, Is.EqualTo(3)); + } + + /// + /// A TextBox collapses its selection when it loses focus, which opening the menu can + /// cause. The insert replaces the span the request snapshotted, not what is left. + /// + [AvaloniaTest] + public void ReplaceEditorSelection_ReplacesTheSnapshottedSpan_AfterTheEditorCollapsedIts() + { + var (row, _) = Show(new FakeTextEditing()); + var editor = Editor(row, "e1"); + editor.Focus(); + editor.SelectionStart = 1; + editor.SelectionEnd = 2; + Dispatcher.UIThread.RunJobs(); + var request = LabelMenuRequest(row); + Assert.That(request.EditorSelectionAnchor, Is.EqualTo(1), "precondition: the span is snapshotted"); + Assert.That(request.EditorSelectionEnd, Is.EqualTo(2)); + + editor.ClearSelection(); // what the menu taking focus does to the editor + Dispatcher.UIThread.RunJobs(); + request.ReplaceEditorSelection("#", 0); + + Assert.That(editor.Text, Is.EqualTo("/##"), "the selected bar was replaced, not kept"); + Assert.That(editor.CaretIndex, Is.EqualTo(2)); + } + + /// + /// Opening a menu can move keyboard focus off the editor. That blur is not the user + /// leaving the item, so the commit waits, and the gesture's end puts focus back. + /// + [AvaloniaTest] + public void AMenuGesture_HoldsTheCommit_WhileTheMenuHasFocus_AndReturnsFocusAfter() + { + var context = new FakeTextEditing(); + var row = new FwReferenceVectorField(Row(), "PhoneEnv", context); + var elsewhere = new TextBox(); + var window = new Window + { + Content = new StackPanel { Children = { row, elsewhere } }, + Width = 480, + Height = 200 + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + var editor = Editor(row, "e1"); + editor.Focus(); + editor.Text = "/_#x"; + editor.CaretIndex = editor.Text.Length; + Dispatcher.UIThread.RunJobs(); + var request = LabelMenuRequest(row); + + request.BeginMenuGesture(); + elsewhere.Focus(); // the menu taking focus + Dispatcher.UIThread.RunJobs(); + Assert.That(context.Edits, Is.Empty, "the blur during the gesture commits nothing"); + + request.ReplaceEditorSelection("_", 0); + request.EndMenuGesture(); + Dispatcher.UIThread.RunJobs(); + Assert.That(editor.IsFocused, Is.True, "the gesture's end returns focus to the editor"); + Assert.That(context.Edits, Is.Empty, "and still commits nothing"); + + elsewhere.Focus(); // the user leaving the item + Dispatcher.UIThread.RunJobs(); + Assert.That(context.Edits, Is.EqualTo(new[] { ("e1", "/_#x_") }), + "leaving the editor commits the typed and inserted text once"); + } + + [AvaloniaTest] + public void AMenuGesture_ThatNeverTookFocus_LeavesFocusWhereItWas() + { + var row = new FwReferenceVectorField(Row(), "PhoneEnv", new FakeTextEditing()); + var elsewhere = new TextBox(); + var window = new Window + { + Content = new StackPanel { Children = { row, elsewhere } }, + Width = 480, + Height = 200 + }; + window.Show(); + Dispatcher.UIThread.RunJobs(); + row.SelectItem("e1"); + elsewhere.Focus(); + Dispatcher.UIThread.RunJobs(); + var request = LabelMenuRequest(row); + + request.BeginMenuGesture(); + request.EndMenuGesture(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(elsewhere.IsFocused, Is.True, "nothing moved focus, so nothing restores it"); + } } } diff --git a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs index 99fb94babf..b3ff026eb7 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/DetailMenuTests.cs @@ -211,6 +211,110 @@ private static void ClickKebab(Button kebab) Dispatcher.UIThread.RunJobs(); } + /// + /// A press on the field-options button must not take focus from the row's editor: + /// the editor would commit its pending text before the menu even opens. SliceTreeNode's + /// menu icon never took focus either. + /// + [AvaloniaTest] + public void FieldMenuButton_DoesNotTakeFocus_FromTheRowsEditor_OnAPointerClick() + { + var (window, view, requests) = Show(Field("Gloss", DetailFieldKind.Text, menuId: "mnuDataTree-Help")); + var editor = view.GetVisualDescendants().OfType().First(); + editor.Focus(); + Dispatcher.UIThread.RunJobs(); + var kebab = Find