From 2fc7d12ae70589b16a0dc6eb3ab4c698192d20e1 Mon Sep 17 00:00:00 2001 From: reuk Date: Thu, 19 May 2022 16:22:22 +0100 Subject: [PATCH] TextEditor: Add option to dismiss the virtual keyboard on touches outside Previously, individual components had to ask the peer to hide and show the keyboard, by calling textInputRequired() and dismissPendingTextInput() respectively. When an onscreen keyboard (OSK) was required, most Peer implementation would directly hide/show the OSK inside these function. However, the iOS ComponentPeer implementation instead listened to the application's global keyboard focus, and only opened the OSK when the focused component was also a TextInputTarget with active input. The iOS scheme seems like a better design, as it enforces that the OSK hiding and showing is synced with the keyboard focus of the application. In the other implementations, it was possible for a Component to call textInputRequired even when it didn't have the keyboard focus, putting the application into an inconsistent state. The iOS scheme also makes the TextInputTarget interface more useful, as it enforces that the OSK will only display for components that implement TextInputTarget, and return true from isTextInputActive(). This patch changes all Peer implementations to match the iOS implementation, improving consistency. Each time the global keyboard focus changes, refreshTextInputTarget is called automatically, and the OSK is shown if the focused component is a TextInputTarget that returns true from isTextInputActive, and hidden otherwise. Components can also call refreshTextInputTarget manually. This should be done whenever the component updates the return value of isTextInputActive(). Effectively, the Peer is now responsible for keeping track of the focused TextInputTarget, rather than allowing individual components to hide and show the OSK at will. Additionally, this patch adds an option to the TextEditor to automatically dismiss the OSK when the mouse is clicked outside of the editor. This should improve user experience on mobile platforms, where touches on sibling components may cause a TextEditor to gain keyboard focus and unnecessarily display the OSK. --- .../components/juce_Component.cpp | 8 +++ .../native/juce_android_Windowing.cpp | 2 + .../native/juce_ios_UIViewComponentPeer.mm | 49 +++++++------------ .../native/juce_win32_Windowing.cpp | 9 +++- .../juce_gui_basics/widgets/juce_Label.cpp | 3 -- .../widgets/juce_TextEditor.cpp | 42 +++++++++++----- .../juce_gui_basics/widgets/juce_TextEditor.h | 18 +++++++ .../windows/juce_ComponentPeer.cpp | 23 ++++++++- .../windows/juce_ComponentPeer.h | 40 ++++++++++----- 9 files changed, 131 insertions(+), 63 deletions(-) diff --git a/modules/juce_gui_basics/components/juce_Component.cpp b/modules/juce_gui_basics/components/juce_Component.cpp index 33467ec096..8b75f39c15 100644 --- a/modules/juce_gui_basics/components/juce_Component.cpp +++ b/modules/juce_gui_basics/components/juce_Component.cpp @@ -2943,6 +2943,11 @@ void Component::takeKeyboardFocus (FocusChangeType cause) return; WeakReference componentLosingFocus (currentlyFocusedComponent); + + if (auto* losingFocus = componentLosingFocus.get()) + if (auto* otherPeer = losingFocus->getPeer()) + otherPeer->closeInputMethodContext(); + currentlyFocusedComponent = this; Desktop::getInstance().triggerFocusCallback(); @@ -3008,6 +3013,9 @@ void Component::giveAwayKeyboardFocusInternal (bool sendFocusLossEvent) { if (auto* componentLosingFocus = currentlyFocusedComponent) { + if (auto* otherPeer = componentLosingFocus->getPeer()) + otherPeer->closeInputMethodContext(); + currentlyFocusedComponent = nullptr; if (sendFocusLossEvent && componentLosingFocus != nullptr) diff --git a/modules/juce_gui_basics/native/juce_android_Windowing.cpp b/modules/juce_gui_basics/native/juce_android_Windowing.cpp index b794429f66..73c82fdd99 100644 --- a/modules/juce_gui_basics/native/juce_android_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_android_Windowing.cpp @@ -942,6 +942,8 @@ public: void dismissPendingTextInput() override { + closeInputMethodContext(); + view.callVoidMethod (ComponentPeerView.showKeyboard, javaString ("").get()); if (! isTimerRunning()) diff --git a/modules/juce_gui_basics/native/juce_ios_UIViewComponentPeer.mm b/modules/juce_gui_basics/native/juce_ios_UIViewComponentPeer.mm index 5f7ffdf429..1bd9f279ec 100644 --- a/modules/juce_gui_basics/native/juce_ios_UIViewComponentPeer.mm +++ b/modules/juce_gui_basics/native/juce_ios_UIViewComponentPeer.mm @@ -192,7 +192,6 @@ struct UIViewPeerControllerReceiver }; class UIViewComponentPeer : public ComponentPeer, - public FocusChangeListener, public UIViewPeerControllerReceiver { public: @@ -240,10 +239,10 @@ public: bool isFocused() const override; void grabFocus() override; void textInputRequired (Point, TextInputTarget&) override; + void dismissPendingTextInput() override; BOOL textViewReplaceCharacters (Range, const String&); - void updateHiddenTextContent (TextInputTarget*); - void globalFocusChanged (Component*) override; + void updateHiddenTextContent (TextInputTarget&); void updateScreenBounds(); @@ -700,8 +699,6 @@ UIViewComponentPeer::UIViewComponentPeer (Component& comp, int windowStyleFlags, setTitle (component.getName()); setVisible (component.isVisible()); - - Desktop::getInstance().addFocusChangeListener (this); } static UIViewComponentPeer* currentlyFocusedPeer = nullptr; @@ -712,7 +709,6 @@ UIViewComponentPeer::~UIViewComponentPeer() currentlyFocusedPeer = nullptr; currentTouches.deleteAllTouchesForPeer (this); - Desktop::getInstance().removeFocusChangeListener (this); view->owner = nullptr; [view removeFromSuperview]; @@ -1076,8 +1072,18 @@ void UIViewComponentPeer::grabFocus() } } -void UIViewComponentPeer::textInputRequired (Point, TextInputTarget&) +void UIViewComponentPeer::textInputRequired (Point pos, TextInputTarget& target) { + view->hiddenTextView.frame = CGRectMake (pos.x, pos.y, 0, 0); + + updateHiddenTextContent (target); + [view->hiddenTextView becomeFirstResponder]; +} + +void UIViewComponentPeer::dismissPendingTextInput() +{ + closeInputMethodContext(); + [view->hiddenTextView resignFirstResponder]; } static UIKeyboardType getUIKeyboardType (TextInputTarget::VirtualKeyboardType type) noexcept @@ -1096,11 +1102,11 @@ static UIKeyboardType getUIKeyboardType (TextInputTarget::VirtualKeyboardType ty return UIKeyboardTypeDefault; } -void UIViewComponentPeer::updateHiddenTextContent (TextInputTarget* target) +void UIViewComponentPeer::updateHiddenTextContent (TextInputTarget& target) { - view->hiddenTextView.keyboardType = getUIKeyboardType (target->getKeyboardType()); - view->hiddenTextView.text = juceStringToNS (target->getTextInRange (Range (0, target->getHighlightedRegion().getStart()))); - view->hiddenTextView.selectedRange = NSMakeRange ((NSUInteger) target->getHighlightedRegion().getStart(), 0); + view->hiddenTextView.keyboardType = getUIKeyboardType (target.getKeyboardType()); + view->hiddenTextView.text = juceStringToNS (target.getTextInRange (Range (0, target.getHighlightedRegion().getStart()))); + view->hiddenTextView.selectedRange = NSMakeRange ((NSUInteger) target.getHighlightedRegion().getStart(), 0); } BOOL UIViewComponentPeer::textViewReplaceCharacters (Range range, const String& text) @@ -1121,31 +1127,12 @@ BOOL UIViewComponentPeer::textViewReplaceCharacters (Range range, const Str target->insertTextAtCaret (text); if (deletionChecker != nullptr) - updateHiddenTextContent (target); + updateHiddenTextContent (*target); } return NO; } -void UIViewComponentPeer::globalFocusChanged (Component*) -{ - if (auto* target = findCurrentTextInputTarget()) - { - if (auto* comp = dynamic_cast (target)) - { - auto pos = component.getLocalPoint (comp, Point()); - view->hiddenTextView.frame = CGRectMake (pos.x, pos.y, 0, 0); - - updateHiddenTextContent (target); - [view->hiddenTextView becomeFirstResponder]; - } - } - else - { - [view->hiddenTextView resignFirstResponder]; - } -} - //============================================================================== void UIViewComponentPeer::drawRect (CGRect r) { diff --git a/modules/juce_gui_basics/native/juce_win32_Windowing.cpp b/modules/juce_gui_basics/native/juce_win32_Windowing.cpp index 14a6d9bca6..eb1c937774 100644 --- a/modules/juce_gui_basics/native/juce_win32_Windowing.cpp +++ b/modules/juce_gui_basics/native/juce_win32_Windowing.cpp @@ -4107,13 +4107,18 @@ private: { if (compositionInProgress && ! windowIsActive) { - compositionInProgress = false; - if (HIMC hImc = ImmGetContext (hWnd)) { ImmNotifyIME (hImc, NI_COMPOSITIONSTR, CPS_COMPLETE, 0); ImmReleaseContext (hWnd, hImc); } + + // If the composition is still in progress, calling ImmNotifyIME may call back + // into handleComposition to let us know that the composition has finished. + // We need to set compositionInProgress *after* calling handleComposition, so that + // the text replaces the current selection, rather than being inserted after the + // caret. + compositionInProgress = false; } } diff --git a/modules/juce_gui_basics/widgets/juce_Label.cpp b/modules/juce_gui_basics/widgets/juce_Label.cpp index f654678918..c185b42040 100644 --- a/modules/juce_gui_basics/widgets/juce_Label.cpp +++ b/modules/juce_gui_basics/widgets/juce_Label.cpp @@ -208,9 +208,6 @@ void Label::editorShown (TextEditor* textEditor) void Label::editorAboutToBeHidden (TextEditor* textEditor) { - if (auto* peer = getPeer()) - peer->dismissPendingTextInput(); - Component::BailOutChecker checker (this); listeners.callChecked (checker, [this, textEditor] (Label::Listener& l) { l.editorHidden (this, *textEditor); }); diff --git a/modules/juce_gui_basics/widgets/juce_TextEditor.cpp b/modules/juce_gui_basics/widgets/juce_TextEditor.cpp index 308ba8e687..41bcb17e05 100644 --- a/modules/juce_gui_basics/widgets/juce_TextEditor.cpp +++ b/modules/juce_gui_basics/widgets/juce_TextEditor.cpp @@ -933,13 +933,13 @@ TextEditor::TextEditor (const String& name, juce_wchar passwordChar) setWantsKeyboardFocus (true); recreateCaret(); + + juce::Desktop::getInstance().addGlobalMouseListener (this); } TextEditor::~TextEditor() { - if (wasFocused) - if (auto* peer = getPeer()) - peer->dismissPendingTextInput(); + juce::Desktop::getInstance().removeGlobalMouseListener (this); textValue.removeListener (textHolder); textValue.referTo (Value()); @@ -1017,9 +1017,17 @@ void TextEditor::setReadOnly (bool shouldBeReadOnly) readOnly = shouldBeReadOnly; enablementChanged(); invalidateAccessibilityHandler(); + + if (auto* peer = getPeer()) + peer->refreshTextInputTarget(); } } +void TextEditor::setClicksOutsideDismissVirtualKeyboard (bool newValue) +{ + clicksOutsideDismissVirtualKeyboard = newValue; +} + bool TextEditor::isReadOnly() const noexcept { return readOnly || ! isEnabled(); @@ -1027,7 +1035,7 @@ bool TextEditor::isReadOnly() const noexcept bool TextEditor::isTextInputActive() const { - return ! isReadOnly(); + return ! isReadOnly() && (! clicksOutsideDismissVirtualKeyboard || mouseDownInEditor); } void TextEditor::setReturnKeyStartsNewLine (bool shouldStartNewLine) @@ -1322,13 +1330,7 @@ void TextEditor::timerCallbackInt() void TextEditor::checkFocus() { if (! wasFocused && hasKeyboardFocus (false) && ! isCurrentlyBlockedByAnotherModalComponent()) - { wasFocused = true; - - if (auto* peer = getPeer()) - if (! isReadOnly()) - peer->textInputRequired (peer->globalToLocal (getScreenPosition()), *this); - } } void TextEditor::repaintText (Range range) @@ -1827,6 +1829,11 @@ void TextEditor::performPopupMenuAction (const int menuItemID) //============================================================================== void TextEditor::mouseDown (const MouseEvent& e) { + mouseDownInEditor = e.originalComponent == this; + + if (! mouseDownInEditor) + return; + beginDragAutoRepeat (100); newTransaction(); @@ -1865,6 +1872,9 @@ void TextEditor::mouseDown (const MouseEvent& e) void TextEditor::mouseDrag (const MouseEvent& e) { + if (! mouseDownInEditor) + return; + if (wasFocused || ! selectAllTextWhenFocused) if (! (popupMenuEnabled && e.mods.isPopupMenu())) moveCaretTo (getTextIndexAt (e.x, e.y), true); @@ -1872,6 +1882,9 @@ void TextEditor::mouseDrag (const MouseEvent& e) void TextEditor::mouseUp (const MouseEvent& e) { + if (! mouseDownInEditor) + return; + newTransaction(); textHolder->restartTimer(); @@ -1884,6 +1897,9 @@ void TextEditor::mouseUp (const MouseEvent& e) void TextEditor::mouseDoubleClick (const MouseEvent& e) { + if (! mouseDownInEditor) + return; + int tokenEnd = getTextIndexAt (e.x, e.y); int tokenStart = 0; @@ -1950,6 +1966,9 @@ void TextEditor::mouseDoubleClick (const MouseEvent& e) void TextEditor::mouseWheelMove (const MouseEvent& e, const MouseWheelDetails& wheel) { + if (! mouseDownInEditor) + return; + if (! viewport->useMouseWheelMoveIfNeeded (e, wheel)) Component::mouseWheelMove (e, wheel); } @@ -2214,9 +2233,6 @@ void TextEditor::focusLost (FocusChangeType) underlinedSections.clear(); - if (auto* peer = getPeer()) - peer->dismissPendingTextInput(); - updateCaretPosition(); postCommandMessage (TextEditorDefs::focusLossMessageId); diff --git a/modules/juce_gui_basics/widgets/juce_TextEditor.h b/modules/juce_gui_basics/widgets/juce_TextEditor.h index ce54424829..5c5a4fadbf 100644 --- a/modules/juce_gui_basics/widgets/juce_TextEditor.h +++ b/modules/juce_gui_basics/widgets/juce_TextEditor.h @@ -665,8 +665,24 @@ public: void setInputRestrictions (int maxTextLength, const String& allowedCharacters = String()); + /** Sets the type of virtual keyboard that should be displayed when this editor has + focus. + */ void setKeyboardType (VirtualKeyboardType type) noexcept { keyboardType = type; } + /** Sets the behaviour of mouse/touch interactions outside this component. + + If true, then presses outside of the TextEditor will dismiss the virtual keyboard. + If false, then the virtual keyboard will remain onscreen for as long as the TextEditor has + keyboard focus. + */ + void setClicksOutsideDismissVirtualKeyboard (bool); + + /** Returns true if the editor is configured to hide the virtual keyboard when the mouse is + pressed on another component. + */ + bool getClicksOutsideDismissVirtualKeyboard() const { return clicksOutsideDismissVirtualKeyboard; } + //============================================================================== /** This abstract base class is implemented by LookAndFeel classes to provide TextEditor drawing functionality. @@ -765,6 +781,8 @@ private: bool valueTextNeedsUpdating = false; bool consumeEscAndReturnKeys = true; bool underlineWhitespace = true; + bool mouseDownInEditor = false; + bool clicksOutsideDismissVirtualKeyboard = false; UndoManager undoManager; std::unique_ptr caret; diff --git a/modules/juce_gui_basics/windows/juce_ComponentPeer.cpp b/modules/juce_gui_basics/windows/juce_ComponentPeer.cpp index 47759c7522..4ca1de4d5e 100644 --- a/modules/juce_gui_basics/windows/juce_ComponentPeer.cpp +++ b/modules/juce_gui_basics/windows/juce_ComponentPeer.cpp @@ -34,12 +34,15 @@ ComponentPeer::ComponentPeer (Component& comp, int flags) styleFlags (flags), uniqueID (lastUniquePeerID += 2) // increment by 2 so that this can never hit 0 { - Desktop::getInstance().peers.add (this); + auto& desktop = Desktop::getInstance(); + desktop.peers.add (this); + desktop.addFocusChangeListener (this); } ComponentPeer::~ComponentPeer() { auto& desktop = Desktop::getInstance(); + desktop.removeFocusChangeListener (this); desktop.peers.removeFirstMatchingValue (this); desktop.triggerFocusCallback(); } @@ -262,6 +265,19 @@ void ComponentPeer::handleModifierKeysChange() target->internalModifierKeysChanged(); } +void ComponentPeer::refreshTextInputTarget() +{ + const auto* lastTarget = std::exchange (textInputTarget, findCurrentTextInputTarget()); + + if (lastTarget == textInputTarget) + return; + + if (textInputTarget == nullptr) + dismissPendingTextInput(); + else if (auto* c = Component::getCurrentlyFocusedComponent()) + textInputRequired (globalToLocal (c->getScreenPosition()), *textInputTarget); +} + TextInputTarget* ComponentPeer::findCurrentTextInputTarget() { auto* c = Component::getCurrentlyFocusedComponent(); @@ -591,4 +607,9 @@ void ComponentPeer::forceDisplayUpdate() Desktop::getInstance().displays->refresh(); } +void ComponentPeer::globalFocusChanged (Component*) +{ + refreshTextInputTarget(); +} + } // namespace juce diff --git a/modules/juce_gui_basics/windows/juce_ComponentPeer.h b/modules/juce_gui_basics/windows/juce_ComponentPeer.h index e227f42da2..ff99144ab5 100644 --- a/modules/juce_gui_basics/windows/juce_ComponentPeer.h +++ b/modules/juce_gui_basics/windows/juce_ComponentPeer.h @@ -40,7 +40,7 @@ namespace juce @tags{GUI} */ -class JUCE_API ComponentPeer +class JUCE_API ComponentPeer : private FocusChangeListener { public: //============================================================================== @@ -131,7 +131,7 @@ public: ComponentPeer (Component& component, int styleFlags); /** Destructor. */ - virtual ~ComponentPeer(); + ~ComponentPeer() override; //============================================================================== /** Returns the component being represented by this peer. */ @@ -352,25 +352,19 @@ public: /** Called whenever a modifier key is pressed or released. */ void handleModifierKeysChange(); - //============================================================================== - /** Tells the window that text input may be required at the given position. - This may cause things like a virtual on-screen keyboard to appear, depending - on the OS. - */ - virtual void textInputRequired (Point position, TextInputTarget&) = 0; - /** If there's a currently active input-method context - i.e. characters are being composed using multiple keystrokes - this should commit the current state of the - context to the text and clear the context. + context to the text and clear the context. This should not hide the virtual keyboard. */ virtual void closeInputMethodContext(); - /** If there's some kind of OS input-method in progress, this should dismiss it. + /** Alerts the peer that the current text input target has changed somehow. - Overrides of this function should call closeInputMethodContext(). + The peer may hide or show the virtual keyboard as a result of this call. */ - virtual void dismissPendingTextInput(); + void refreshTextInputTarget(); + //============================================================================== /** Returns the currently focused TextInputTarget, or null if none is found. */ TextInputTarget* findCurrentTextInputTarget(); @@ -532,10 +526,30 @@ private: //============================================================================== virtual void appStyleChanged() {} + /** Tells the window that text input may be required at the given position. + This may cause things like a virtual on-screen keyboard to appear, depending + on the OS. + + This function should not be called directly by Components - use refreshTextInputTarget + instead. + */ + virtual void textInputRequired (Point, TextInputTarget&) = 0; + + /** If there's some kind of OS input-method in progress, this should dismiss it. + + Overrides of this function should call closeInputMethodContext(). + + This function should not be called directly by Components - use refreshTextInputTarget + instead. + */ + virtual void dismissPendingTextInput(); + + void globalFocusChanged (Component*) override; Component* getTargetForKeyPress(); WeakReference lastFocusedComponent, dragAndDropTargetComponent; Component* lastDragAndDropCompUnderMouse = nullptr; + TextInputTarget* textInputTarget = nullptr; const uint32 uniqueID; bool isWindowMinimised = false;