From 78d4f2b1ffd2cf54cc53824552a449bde8efcd26 Mon Sep 17 00:00:00 2001 From: Jordan Martinez Date: Fri, 24 Mar 2017 22:42:57 -0700 Subject: [PATCH] Rewrite default mouse behavior to be more overridable-friendly --- .../fxmisc/richtext/GenericStyledArea.java | 125 +++++++++++- .../richtext/StyledTextAreaBehavior.java | 186 +++++++++++------- .../java/org/fxmisc/richtext/ViewActions.java | 115 ++++++++++- 3 files changed, 333 insertions(+), 93 deletions(-) diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java index 4c30897de..7f0ca78d6 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/GenericStyledArea.java @@ -15,7 +15,6 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; -import java.util.function.IntConsumer; import java.util.function.IntFunction; import java.util.function.IntSupplier; import java.util.function.IntUnaryOperator; @@ -47,6 +46,7 @@ import javafx.scene.Node; import javafx.scene.control.ContextMenu; import javafx.scene.control.IndexRange; +import javafx.scene.input.MouseEvent; import javafx.scene.layout.Background; import javafx.scene.layout.BackgroundFill; import javafx.scene.layout.CornerRadii; @@ -159,12 +159,75 @@ * *

Overriding default mouse behavior

* - * The area's default mouse behavior cannot be partially overridden without it affecting other behavior (to do so, - * one would need to re-implement the entire default behavior with one minor adjustment). Rather, one should - * override the default mouse behavior by changing what happens at various events. - * For example, {@link #getOnSelectionDrop()} overrides what happens when some portion of the area's content is - * selected, then the mouse is pressed on that selection, the mouse moves to a new location, and the mouse is released. - * At that point, {@link #onSelectionDrop} is used to determine what should happen. + * The area's default mouse behavior properly handles auto-scrolling and dragging the selected text to a new location. + * As such, some parts cannot be partially overridden without it affecting other behavior. + * + *

The following lists either {@link org.fxmisc.wellbehaved.event.EventPattern}s that cannot be overridden without + * negatively affecting the default mouse behavior or describe how to safely override things in a special way without + * disrupting the auto scroll behavior.

+ * + * * * @param type of style that can be applied to paragraphs (e.g. {@link TextFlow}. * @param type of segment used in {@link Paragraph}. Can be only text (plain or styled) or @@ -266,9 +329,51 @@ private static int clamp(int min, int val, int max) { @Override public Duration getMouseOverTextDelay() { return mouseOverTextDelay.get(); } @Override public ObjectProperty mouseOverTextDelayProperty() { return mouseOverTextDelay; } - private final Property onSelectionDrop = new SimpleObjectProperty<>(this::moveSelectedText); - @Override public final void setOnSelectionDrop(IntConsumer consumer) { onSelectionDrop.setValue(consumer); } - @Override public final IntConsumer getOnSelectionDrop() { return onSelectionDrop.getValue(); } + private final BooleanProperty autoScrollOnDragDesired = new SimpleBooleanProperty(true); + public final void setAutoScrollOnDragDesired(boolean val) { autoScrollOnDragDesired.set(val); } + public final boolean isAutoScrollOnDragDesired() { return autoScrollOnDragDesired.get(); } + + private final Property> onOutsideSelectionMousePress = new SimpleObjectProperty<>(e -> { + CharacterHit hit = hit(e.getX(), e.getY()); + moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); + }); + public final void setOnOutsideSelectionMousePress(Consumer consumer) { onOutsideSelectionMousePress.setValue(consumer); } + public final Consumer getOnOutsideSelectionMousePress() { return onOutsideSelectionMousePress.getValue(); } + + private final Property> onInsideSelectionMousePressRelease = new SimpleObjectProperty<>(e -> { + CharacterHit hit = hit(e.getX(), e.getY()); + moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); + }); + public final void setOnInsideSelectionMousePressRelease(Consumer consumer) { onInsideSelectionMousePressRelease.setValue(consumer); } + public final Consumer getOnInsideSelectionMousePressRelease() { return onInsideSelectionMousePressRelease.getValue(); } + + private final Property> onNewSelectionDrag = new SimpleObjectProperty<>(p -> { + CharacterHit hit = hit(p.getX(), p.getY()); + moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST); + }); + public final void setOnNewSelectionDrag(Consumer consumer) { onNewSelectionDrag.setValue(consumer); } + public final Consumer getOnNewSelectionDrag() { return onNewSelectionDrag.getValue(); } + + private final Property> onNewSelectionDragEnd = new SimpleObjectProperty<>(e -> { + CharacterHit hit = hit(e.getX(), e.getY()); + moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST); + }); + public final void setOnNewSelectionDragEnd(Consumer consumer) { onNewSelectionDragEnd.setValue(consumer); } + public final Consumer getOnNewSelectionDragEnd() { return onNewSelectionDragEnd.getValue(); } + + private final Property> onSelectionDrag = new SimpleObjectProperty<>(p -> { + CharacterHit hit = hit(p.getX(), p.getY()); + displaceCaret(hit.getInsertionIndex()); + }); + public final void setOnSelectionDrag(Consumer consumer) { onSelectionDrag.setValue(consumer); } + public final Consumer getOnSelectionDrag() { return onSelectionDrag.getValue(); } + + private final Property> onSelectionDrop = new SimpleObjectProperty<>(e -> { + CharacterHit hit = hit(e.getX(), e.getY()); + moveSelectedText(hit.getInsertionIndex()); + }); + @Override public final void setOnSelectionDrop(Consumer consumer) { onSelectionDrop.setValue(consumer); } + @Override public final Consumer getOnSelectionDrop() { return onSelectionDrop.getValue(); } private final ObjectProperty> paragraphGraphicFactory = new SimpleObjectProperty<>(null); @Override diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java index 9de7a428f..928e9b39c 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/StyledTextAreaBehavior.java @@ -23,6 +23,7 @@ import org.fxmisc.richtext.model.NavigationActions.SelectionPolicy; import org.fxmisc.richtext.model.TwoDimensional.Position; import org.fxmisc.wellbehaved.event.EventPattern; +import org.fxmisc.wellbehaved.event.InputHandler.Result; import org.fxmisc.wellbehaved.event.template.InputMapTemplate; import org.reactfx.EventStream; import org.reactfx.value.Val; @@ -177,11 +178,66 @@ class StyledTextAreaBehavior { ).ifConsumed((b, e) -> b.view.requestFollowCaret()); InputMapTemplate keyTypedTemplate = when(b -> b.view.isEditable(), keyTypedBase); - InputMapTemplate mouseEventTemplate = sequence( - consume(eventType(MouseEvent.MOUSE_PRESSED), StyledTextAreaBehavior::mousePressed), - consume(eventType(MouseEvent.MOUSE_DRAGGED), StyledTextAreaBehavior::mouseDragged), - consume(eventType(MouseEvent.DRAG_DETECTED), StyledTextAreaBehavior::dragDetected), - consume(eventType(MouseEvent.MOUSE_RELEASED), StyledTextAreaBehavior::mouseReleased) + InputMapTemplate mousePressedTemplate = sequence( + // ignore mouse pressed events if the view is disabled + process(mousePressed(MouseButton.PRIMARY), (b, e) -> b.view.isDisabled() ? Result.IGNORE : Result.PROCEED), + + // hide context menu before any other handling + process( + mousePressed(), (b, e) -> { + b.view.hideContextMenu(); + return Result.PROCEED; + } + ), + consume( + mousePressed(MouseButton.PRIMARY).onlyIf(MouseEvent::isShiftDown), + StyledTextAreaBehavior::handleShiftPress + ), + consume( + mousePressed(MouseButton.PRIMARY).onlyIf(e -> e.getClickCount() == 1), + StyledTextAreaBehavior::handleFirstPrimaryPress + ), + consume( + mousePressed(MouseButton.PRIMARY).onlyIf(e -> e.getClickCount() == 2), + StyledTextAreaBehavior::handleSecondPress + ), + consume( + mousePressed(MouseButton.PRIMARY).onlyIf(e -> e.getClickCount() == 3), + StyledTextAreaBehavior::handleThirdPress + ) + ); + + Predicate primaryOnlyButton = e -> e.getButton() == MouseButton.PRIMARY && !e.isMiddleButtonDown() && !e.isSecondaryButtonDown(); + + InputMapTemplate mouseDragDetectedTemplate = consume( + eventType(MouseEvent.DRAG_DETECTED).onlyIf(primaryOnlyButton), + (b, e) -> b.handlePrimaryOnlyDragDetected() + ); + + InputMapTemplate mouseDragTemplate = sequence( + process( + mouseDragged().onlyIf(primaryOnlyButton), + StyledTextAreaBehavior::processPrimaryOnlyMouseDragged + ), + consume( + mouseDragged(), + StyledTextAreaBehavior::continueOrStopAutoScroll + ) + ); + + InputMapTemplate mouseReleasedTemplate = sequence( + process( + EventPattern.mouseReleased().onlyIf(primaryOnlyButton), + StyledTextAreaBehavior::processMouseReleased + ), + consume( + mouseReleased(), + (b, e) -> b.autoscrollTo.setValue(null) // stop auto scroll + ) + ); + + InputMapTemplate mouseTemplate = sequence( + mousePressedTemplate, mouseDragDetectedTemplate, mouseDragTemplate, mouseReleasedTemplate ); InputMapTemplate contextMenuEventTemplate = consumeWhen( @@ -190,7 +246,7 @@ class StyledTextAreaBehavior { StyledTextAreaBehavior::showContextMenu ); - EVENT_TEMPLATE = sequence(mouseEventTemplate, keyPressedTemplate, keyTypedTemplate, contextMenuEventTemplate); + EVENT_TEMPLATE = sequence(mouseTemplate, keyPressedTemplate, keyTypedTemplate, contextMenuEventTemplate); } /** @@ -200,7 +256,7 @@ private enum DragState { /** No dragging is happening. */ NO_DRAG, - /** Mouse has been pressed, but drag has not been detected yet. */ + /** Mouse has been pressed inside of selected text, but drag has not been detected yet. */ POTENTIAL_DRAG, /** Drag in progress. */ @@ -392,40 +448,23 @@ private void showContextMenu(ContextMenuEvent e) { menu.show(view, e.getScreenX() + xOffset, e.getScreenY() + yOffset); } - private void mousePressed(MouseEvent e) { - // don't respond if disabled - if(view.isDisabled()) { - return; - } + private void handleShiftPress(MouseEvent e) { + // ensure focus + view.requestFocus(); - view.hideContextMenu(); - - if(e.getButton() == MouseButton.PRIMARY) { - // ensure focus - view.requestFocus(); - - CharacterHit hit = view.hit(e.getX(), e.getY()); - - if(e.isShiftDown()) { - // On Mac always extend selection, - // switching anchor and caret if necessary. - view.moveTo( - hit.getInsertionIndex(), - isMac ? SelectionPolicy.EXTEND : SelectionPolicy.ADJUST); - } else { - switch (e.getClickCount()) { - case 1: firstLeftPress(hit); break; - case 2: view.selectWord(); break; - case 3: view.selectParagraph(); break; - default: // do nothing - } - } + CharacterHit hit = view.hit(e.getX(), e.getY()); - e.consume(); - } + // On Mac always extend selection, + // switching anchor and caret if necessary. + view.moveTo(hit.getInsertionIndex(), isMac ? SelectionPolicy.EXTEND : SelectionPolicy.ADJUST); } - private void firstLeftPress(CharacterHit hit) { + private void handleFirstPrimaryPress(MouseEvent e) { + // ensure focus + view.requestFocus(); + + CharacterHit hit = view.hit(e.getX(), e.getY()); + view.clearTargetCaretOffset(); IndexRange selection = view.getSelection(); if(view.isEditable() && @@ -437,78 +476,73 @@ private void firstLeftPress(CharacterHit hit) { dragSelection = DragState.POTENTIAL_DRAG; } else { dragSelection = DragState.NO_DRAG; - view.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); + view.getOnOutsideSelectionMousePress().accept(e); } } - private void dragDetected(MouseEvent e) { - if(dragSelection == DragState.POTENTIAL_DRAG) { + private void handleSecondPress(MouseEvent e) { + view.selectWord(); + } + + private void handleThirdPress(MouseEvent e) { + view.selectParagraph(); + } + + private void handlePrimaryOnlyDragDetected() { + if (dragSelection == DragState.POTENTIAL_DRAG) { dragSelection = DragState.DRAG; } - e.consume(); } - private void mouseDragged(MouseEvent e) { - // don't respond if disabled - if(view.isDisabled()) { - return; + private Result processPrimaryOnlyMouseDragged(MouseEvent e) { + Point2D p = new Point2D(e.getX(), e.getY()); + if(view.getLayoutBounds().contains(p)) { + dragTo(p); } + view.setAutoScrollOnDragDesired(true); + // autoScrollTo will be set in "continueOrStopAutoScroll(MouseEvent)" + return Result.PROCEED; + } - // only respond to primary button alone - if(e.getButton() != MouseButton.PRIMARY || e.isMiddleButtonDown() || e.isSecondaryButtonDown()) { - return; + private void continueOrStopAutoScroll(MouseEvent e) { + if (!view.isAutoScrollOnDragDesired()) { + autoscrollTo.setValue(null); // stops auto-scroll } Point2D p = new Point2D(e.getX(), e.getY()); if(view.getLayoutBounds().contains(p)) { - dragTo(p); autoscrollTo.setValue(null); // stops auto-scroll } else { autoscrollTo.setValue(p); // starts auto-scroll } - - e.consume(); } - private void dragTo(Point2D p) { - CharacterHit hit = view.hit(p.getX(), p.getY()); - + private void dragTo(Point2D point) { if(dragSelection == DragState.DRAG || dragSelection == DragState.POTENTIAL_DRAG) { // MOUSE_DRAGGED may arrive even before DRAG_DETECTED - view.displaceCaret(hit.getInsertionIndex()); + view.getOnSelectionDrag().accept(point); } else { - view.moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST); + view.getOnNewSelectionDrag().accept(point); } } - private void mouseReleased(MouseEvent e) { - // stop auto-scroll - autoscrollTo.setValue(null); - - // don't respond if disabled - if(view.isDisabled()) { - return; + private Result processMouseReleased(MouseEvent e) { + if (view.isDisabled()) { + return Result.IGNORE; } switch(dragSelection) { case POTENTIAL_DRAG: - // drag didn't happen, position caret - CharacterHit hit = view.hit(e.getX(), e.getY()); - view.moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR); - break; + // selection was not dragged, but clicked + view.getOnInsideSelectionMousePressRelease().accept(e); case DRAG: - // only handle drags if mouse was released inside of view - if (view.getLayoutBounds().contains(e.getX(), e.getY())) { - // move selection to the target position - CharacterHit h = view.hit(e.getX(), e.getY()); - view.getOnSelectionDrop().accept(h.getInsertionIndex()); - // do nothing, handled by mouseDragReleased - } + view.getOnSelectionDrop().accept(e); case NO_DRAG: - // do nothing, caret already repositioned in mousePressed + // do nothing, caret already repositioned in "handle[Number]Press(MouseEvent)" } dragSelection = DragState.NO_DRAG; - e.consume(); + + return Result.PROCEED; } private static Point2D project(Point2D p, Bounds bounds) { diff --git a/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java b/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java index 47fdb60c0..2179acdfb 100644 --- a/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java +++ b/richtextfx/src/main/java/org/fxmisc/richtext/ViewActions.java @@ -7,6 +7,7 @@ import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.control.ContextMenu; +import javafx.scene.input.MouseEvent; import javafx.scene.text.TextFlow; import org.fxmisc.richtext.model.Codec; import org.fxmisc.richtext.model.NavigationActions; @@ -17,7 +18,7 @@ import java.time.Duration; import java.util.Optional; import java.util.function.BiConsumer; -import java.util.function.IntConsumer; +import java.util.function.Consumer; import java.util.function.IntFunction; public interface ViewActions { @@ -68,15 +69,115 @@ public static enum CaretVisibility { ObjectProperty mouseOverTextDelayProperty(); /** - * Defines how to handle an event in which the user has selected some text, dragged it to a - * new location within the area, and released the mouse at some character {@code index} - * within the area. + * Indicates whether area should auto scroll towards a {@link MouseEvent#MOUSE_DRAGGED} event. This can be + * used when additional drag behavior is added on top of the area's default drag behavior and one does not + * want this auto scroll feature to occur. This flag should be set to the correct value before the end of + * the process InputMap. + */ + boolean isAutoScrollOnDragDesired(); + void setAutoScrollOnDragDesired(boolean val); + + /** + * Runs the consumer when the user pressed the mouse over unselected text within the area. + * + *

By default, this will {@link NavigationActions#moveTo(int) move the caret} to the position where + * the mouse was pressed and clear out any selection via the code: + *


+     *     e -> {
+     *         CharacterHit hit = hit(e.getX(), e.getY());
+     *         moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR);
+     *     }
+     * 
. + */ + void setOnOutsideSelectionMousePress(Consumer consumer); + Consumer getOnOutsideSelectionMousePress(); + + /** + * Runs the consumer when the mouse is released in this scenario: the user has selected some text and then + * "clicked" the mouse somewhere in that selection (the use pressed the mouse, did not drag it, + * and released the mouse). Note: this consumer is run on {@link MouseEvent#MOUSE_RELEASED}, + * not {@link MouseEvent#MOUSE_CLICKED}. + * + *

By default, this will {@link NavigationActions#moveTo(int) move the caret} to the position where + * the mouse was clicked and clear out any selection via the code: + *


+     *     e -> {
+     *         CharacterHit hit = hit(e.getX(), e.getY());
+     *         moveTo(hit.getInsertionIndex(), SelectionPolicy.CLEAR);
+     *     }
+     * 
. + */ + Consumer getOnInsideSelectionMousePressRelease(); + void setOnInsideSelectionMousePressRelease(Consumer consumer); + + /** + * Runs the consumer when the mouse is dragged in this scenario: the user has selected some text, + * pressed the mouse on top of the selection, dragged it to a new location within the area, + * but has not yet released the mouse. + * + *

By default, this will create a new selection or + * {@link org.fxmisc.richtext.model.NavigationActions.SelectionPolicy#ADJUST} the current one to be bigger or + * smaller via the code: + *


+     *     e -> {
+     *         CharacterHit hit = hit(e.getX(), e.getY());
+     *         moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST);
+     *     }
+     * 
. + */ + Consumer getOnNewSelectionDrag(); + void setOnNewSelectionDrag(Consumer consumer); + + /** + * Runs the consumer when the mouse is released in this scenario: the user has selected some text, + * pressed the mouse on top of the selection, dragged it to a new location within the area, + * and released the mouse. + * + *

By default, this will {@link org.fxmisc.richtext.model.NavigationActions.SelectionPolicy#ADJUST} the + * current selection to be bigger or smaller via the code: + *


+     *     e -> {
+     *         CharacterHit hit = hit(e.getX(), e.getY());
+     *         moveTo(hit.getInsertionIndex(), SelectionPolicy.ADJUST);
+     *     }
+     * 
. + */ + Consumer getOnNewSelectionDragEnd(); + void setOnNewSelectionDragEnd(Consumer consumer); + + /** + * Runs the consumer when the mouse is dragged in this scenario: the user has selected some text, + * pressed the mouse on top of the selection, dragged it to a new location within the area, + * but has not yet released the mouse. + * + *

By default, this will {@link GenericStyledArea#displaceCaret(int) displace the caret} to that position + * within the area via the code: + *


+     *     p -> {
+     *         CharacterHit hit = hit(p.getX(), p.getY());
+     *         displaceCaret(hit.getInsertionIndex());
+     *     }
+     * 
. + */ + Consumer getOnSelectionDrag(); + void setOnSelectionDrag(Consumer consumer); + + /** + * Runs the consumer when the mouse is released in this scenario: the user has selected some text, + * pressed the mouse on top of the selection, dragged it to a new location within the area, + * and released the mouse within the area. * *

By default, this will relocate the selected text to the character index where the mouse - * was released. To override it, use {@link #setOnSelectionDrop(IntConsumer)}. + * was released via the code: + *


+     *     e -> {
+     *         CharacterHit hit = hit(e.getX(), e.getY());
+     *         moveSelectedText(hit.getInsertionIndex());
+     *     }
+     * 
. */ - IntConsumer getOnSelectionDrop(); - void setOnSelectionDrop(IntConsumer consumer); + Consumer getOnSelectionDrop(); + void setOnSelectionDrop(Consumer consumer); /** * Gets the function that maps a line index to a node that is positioned to the left of the first character