diff --git a/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs b/Src/Common/Controls/DetailControls/PhoneEnvReferenceView.cs index 37bafb566e..f4074824c5 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; @@ -350,9 +351,7 @@ internal bool OnRedo(object args) public void ResetValidator() { CheckDisposed(); - m_validator = new PhonEnvRecognizer( - m_cache.LangProject.PhonologicalDataOA.AllPhonemes().ToArray(), - m_cache.LangProject.PhonologicalDataOA.AllNaturalClassAbbrs().ToArray()); + m_validator = EnvironmentErrors.CreateRecognizer(m_cache); } /// @@ -953,16 +952,13 @@ internal void ShowEnvironmentError() { CheckDisposed(); string s; - if (CanGetEnvironmentStringRep(out s)) + if (!CanGetEnvironmentStringRep(out s)) + return; + var message = EnvironmentErrors.Describe(m_validator, s); + if (message != null) { - if (!m_validator.Recognize(s)) - { - string sMsg; - int pos; - StringServices.CreateErrorMessageFromXml(s, m_validator.ErrorMessage, out pos, out sMsg); - MessageBox.Show(sMsg, DetailControlsStrings.ksBadEnv, - MessageBoxButtons.OK, MessageBoxIcon.Information); - } + MessageBox.Show(message, DetailControlsStrings.ksBadEnv, + MessageBoxButtons.OK, MessageBoxIcon.Information); } } @@ -971,10 +967,7 @@ internal bool CanShowEnvironmentError() { CheckDisposed(); string s; - if (CanGetEnvironmentStringRep(out s)) - return (!m_validator.Recognize(s)); - else - return false; + return CanGetEnvironmentStringRep(out s) && EnvironmentErrors.Describe(m_validator, s) != null; } private bool CanGetEnvironmentStringRep(out string s) @@ -995,6 +988,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 +1001,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 +1019,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 +1034,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 +1049,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 d64d064bc1..8b8117878e 100644 --- a/Src/Common/FwAvalonia/Detail/DetailModel.cs +++ b/Src/Common/FwAvalonia/Detail/DetailModel.cs @@ -1856,6 +1856,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 { @@ -1896,8 +1941,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 @@ -1970,6 +2030,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..1bf4a7ae3b 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,18 @@ 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); + _currentEditor = text as TextBox; + if (_currentEditor != null) + TakeEditorOnRightPress(_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 +1549,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 +1662,7 @@ public FwReferenceVectorField( gestureCompleted?.Invoke(); }; EventHandler newOnBlur = - (s2, e2) => commitNew(); + (s2, e2) => CommitUnlessMenuGesture(commitNew); newItem.LostFocus += newOnBlur; EventHandler newOnEnter = (s2, e2) => { @@ -1665,16 +1672,38 @@ 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; + // A right press names the slot as the menu's editor itself, rather than relying + // on the focus change, so the request is right even if focus does not move. + EventHandler newOnRightPress = (s2, e2) => + { + if (!e2.GetCurrentPoint(newItem).Properties.IsRightButtonPressed) + return; + ClearSelection(); + TakeEditorOnRightPress(newItem, e2); + }; + newItem.AddHandler(InputElement.PointerPressedEvent, newOnRightPress, + RoutingStrategies.Bubble, handledEventsToo: true); _teardown.Add(() => { newItem.LostFocus -= newOnBlur; newItem.KeyDown -= newOnEnter; newItem.GotFocus -= newClearsSelection; + newItem.RemoveHandler(InputElement.PointerPressedEvent, newOnRightPress); }); + 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 +1821,132 @@ 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(); + } + + // A right press does not focus a TextBox by itself, so the press makes it the current + // editor, focuses it and puts the caret at the pointer, as PhoneEnvReferenceView does. + private void TakeEditorOnRightPress(TextBox box, PointerPressedEventArgs e) + { + if (!e.GetCurrentPoint(box).Properties.IsRightButtonPressed) + return; + _currentEditor = box; + if (!box.IsFocused) + box.Focus(); + PlaceCaretAtPointer(box, e); + } + + // 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..22e79b614a 100644 --- a/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs +++ b/Src/Common/FwAvalonia/FwAvaloniaTests/Detail/RetypableVectorItemTests.cs @@ -526,5 +526,404 @@ 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"); + } + + // A row that raises menu requests, laid out so a pointer can reach its parts. + private static (FwReferenceVectorField Row, Window Window, List Requests) + ShowWithMenu(FakeTextEditing context) + { + var requests = new List(); + 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(); + return (row, window, requests); + } + + // The row is the window's content, so a part's bounds are window coordinates. + private static void RightClick(Window window, Control control) + { + var point = new Avalonia.Point(control.Bounds.X + control.Bounds.Width / 2, + control.Bounds.Y + control.Bounds.Height / 2); + window.MouseDown(point, MouseButton.Right); + window.MouseUp(point, MouseButton.Right); + Dispatcher.UIThread.RunJobs(); + } + + /// + /// A right press on the slot makes it the menu's editor even though an item had focus: + /// otherwise the insert lands in that item. + /// + [AvaloniaTest] + public void RightClickingTheSlot_WhileAnItemHasFocus_MakesTheSlotTheEditor() + { + var (row, window, requests) = ShowWithMenu(new FakeTextEditing { Creatable = true }); + var item = Editor(row, "e1"); + item.Focus(); + item.CaretIndex = 1; + Dispatcher.UIThread.RunJobs(); + + RightClick(window, NewItemSlot(row)); + + Assert.That(requests, Has.Count.EqualTo(1)); + var request = requests[0]; + Assert.That(request.HasTextEditor, Is.True); + Assert.That(request.EditorText, Is.EqualTo(string.Empty), "the slot, not the item"); + Assert.That(request.SelectedItemKey, Is.Null, "no item is current"); + Assert.That(request.ReplaceEditorSelection("()", 1), Is.True); + Assert.That(NewItemSlot(row).Text, Is.EqualTo("()")); + Assert.That(item.Text, Is.EqualTo("/_#"), "the item is untouched"); + } + + [AvaloniaTest] + public void RightClickingTheSlot_OnARowNotYetClickedInto_MakesTheSlotTheEditor() + { + var (row, window, requests) = ShowWithMenu(new FakeTextEditing { Creatable = true }); + + RightClick(window, NewItemSlot(row)); + + Assert.That(requests, Has.Count.EqualTo(1)); + Assert.That(requests[0].HasTextEditor, Is.True, "so Insert Environment slash is enabled"); + Assert.That(requests[0].EditorText, Is.EqualTo(string.Empty)); + Assert.That(requests[0].EditorSelectionEnd, Is.EqualTo(0)); + } + + /// + /// Right-clicking the slot moves focus off an item holding unsaved text, which stages + /// that text; then the insert leaves the slot holding unsaved text of its own, which is + /// what keeps the host from rebuilding the row until the slot is left. + /// + [AvaloniaTest] + public void RightClickingTheSlot_StagesAnItemsUnsavedText_AndTheInsertStaysPending() + { + var context = new FakeTextEditing { Creatable = true }; + var (row, window, requests) = ShowWithMenu(context); + var item = Editor(row, "e1"); + item.Focus(); + item.Text = "/_#x"; + Dispatcher.UIThread.RunJobs(); + + RightClick(window, NewItemSlot(row)); + var request = requests.Single(); + request.BeginMenuGesture(); + request.ReplaceEditorSelection("/", 0); + request.EndMenuGesture(); + Dispatcher.UIThread.RunJobs(); + + Assert.That(context.Edits, Is.EqualTo(new[] { ("e1", "/_#x") }), "the item's text was staged"); + Assert.That(NewItemSlot(row).Text, Is.EqualTo("/")); + Assert.That(context.Created, Is.Empty, "the slot's text waits for the slot to be left"); + Assert.That(row.HasUnstagedText, Is.True, "which holds the host's refresh"); + } + + [AvaloniaTest] + public void RightClickingAnotherItem_StagesTheFirstItemsUnsavedText() + { + var context = new FakeTextEditing(); + var (row, window, requests) = ShowWithMenu(context); + var first = Editor(row, "e1"); + first.Focus(); + first.Text = "/_#x"; + Dispatcher.UIThread.RunJobs(); + + RightClick(window, Editor(row, "e2")); + + Assert.That(context.Edits, Is.EqualTo(new[] { ("e1", "/_#x") })); + Assert.That(requests.Single().SelectedItemKey, Is.EqualTo("e2")); + Assert.That(requests.Single().EditorText, Is.EqualTo("/_a")); + } + + [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