diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/VirtualizedScrollPaneWrapper.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/VirtualizedScrollPaneWrapper.java index 823cf7144..c978b8502 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/VirtualizedScrollPaneWrapper.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/VirtualizedScrollPaneWrapper.java @@ -1,11 +1,21 @@ package software.coley.recaf.ui.control; import jakarta.annotation.Nonnull; +import javafx.animation.AnimationTimer; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; +import javafx.scene.Cursor; +import javafx.scene.control.ScrollBar; +import javafx.scene.input.MouseButton; import javafx.scene.layout.Region; import org.fxmisc.flowless.Virtualized; import org.fxmisc.flowless.VirtualizedScrollPane; import org.reactfx.value.Var; +import software.coley.collections.Unchecked; +import software.coley.recaf.util.NodeEvents; +import software.coley.recaf.util.ReflectUtil; /** * Wrapper for {@link VirtualizedScrollPane} to properly expose properties with JavaFX's property types instead of @@ -17,8 +27,23 @@ * @author Matt Coley */ public class VirtualizedScrollPaneWrapper extends VirtualizedScrollPane { - private final SimpleDoubleProperty xScroll = new SimpleDoubleProperty(0); - private final SimpleDoubleProperty yScroll = new SimpleDoubleProperty(0); + private static final double AUTO_SCROLL_MULTIPLIER = 0.1; + private static final double AUTO_SCROLL_BUFFER_PX = 5; + private final DoubleProperty xScrollProperty = new SimpleDoubleProperty(0); + private final DoubleProperty yScrollProperty = new SimpleDoubleProperty(0); + private final BooleanProperty canAutoScroll = new SimpleBooleanProperty(true); + private final ScrollBar horizontalScrollbar; + private final ScrollBar verticalScrollbar; + private Cursor preAutoScrollCursor; + private boolean isAutoScrolling; + private double autoScrollStartY; + private double autoScrollCurrentY; + private final AnimationTimer autoScrollTimer = new AnimationTimer() { + @Override + public void handle(long now) { + updateAutoScroll(); + } + }; /** * @param content @@ -26,27 +51,120 @@ public class VirtualizedScrollPaneWrapper extend */ public VirtualizedScrollPaneWrapper(V content) { super(content); + + horizontalScrollbar = Unchecked.get(() -> ReflectUtil.quietGet(this, VirtualizedScrollPane.class.getDeclaredField("hbar"))); + verticalScrollbar = Unchecked.get(() -> ReflectUtil.quietGet(this, VirtualizedScrollPane.class.getDeclaredField("vbar"))); + setup(); } private void setup() { - xScroll.bind(estimatedScrollXProperty()); - yScroll.bind(estimatedScrollYProperty()); + xScrollProperty.bind(estimatedScrollXProperty()); + yScrollProperty.bind(estimatedScrollYProperty()); + + // Handle middle mouse press to start auto-scrolling. + // - Press initiates the auto-scroll + // - Drag changes the auto-scroll speed + // - Release stops the auto-scroll + NodeEvents.addMousePressHandler(getContent(), e -> { + if (canAutoScroll.get() && e.getButton() == MouseButton.MIDDLE) { + preAutoScrollCursor = getContent().getCursor(); + autoScrollStartY = e.getScreenY(); + autoScrollCurrentY = autoScrollStartY; + } + }); + NodeEvents.addMouseReleaseHandler(getContent(), e -> { + if (e.getButton() == MouseButton.MIDDLE && isAutoScrolling()) { + getContent().setCursor(preAutoScrollCursor); + autoScrollTimer.stop(); + autoScrollCurrentY = -1; + isAutoScrolling = false; + } + }); + NodeEvents.addMouseDraggedHandler(getContent(), e -> { + if (e.getButton() == MouseButton.MIDDLE) { + autoScrollCurrentY = e.getScreenY(); + + // Only begin the auto-scroll after the user has moved a couple of pixels away. + // + // We do this because the 'content' node may have middle click behavior similar + // to how a browser opens/closes tabs when using the middle mouse button on links. + // By not initiating until we're sure the user intends to move around via auto-scroll + // we don't mess with the UX of the existing behavior in the 'content' node. + if (!isAutoScrolling && Math.abs(autoScrollCurrentY - autoScrollStartY) > AUTO_SCROLL_BUFFER_PX) { + isAutoScrolling = true; + autoScrollTimer.start(); + getContent().setCursor(Cursor.V_RESIZE); + } + } + }); + } + + private void updateAutoScroll() { + double deltaY = autoScrollCurrentY - autoScrollStartY; + + // Get current scroll values + double value = verticalScrollbar.getValue(); + double min = verticalScrollbar.getMin(); + double max = verticalScrollbar.getMax(); + + // Calculate scroll amount based on viewport size + double viewportHeight = getHeight(); + double scrollAmount = (deltaY * AUTO_SCROLL_MULTIPLIER); + if (Math.abs(scrollAmount) > 0.1) { + // Calculate new scroll position + double newValue = value + scrollAmount; + newValue = Math.max(min, Math.min(max, newValue)); + + // Update scroll position + verticalScrollbar.setValue(newValue); + } + } + + /** + * @return {@code true} when this scroll pane is auto-scrolling. + */ + public boolean isAutoScrolling() { + return isAutoScrolling; + } + + /** + * @return Horizontal scrollbar. + */ + @Nonnull + public ScrollBar getHorizontalScrollbar() { + return horizontalScrollbar; + } + + /** + * @return Vertical scrollbar. + */ + @Nonnull + public ScrollBar getVerticalScrollbar() { + return verticalScrollbar; } /** * @return Horizontal scroll property. */ @Nonnull - public SimpleDoubleProperty horizontalScrollProperty() { - return xScroll; + public DoubleProperty horizontalScrollProperty() { + return xScrollProperty; } /** * @return Vertical scroll property. */ @Nonnull - public SimpleDoubleProperty verticalScrollProperty() { - return yScroll; + public DoubleProperty verticalScrollProperty() { + return yScrollProperty; + } + + /** + * @return Can auto-scroll property. + */ + @Nonnull + public BooleanProperty canAutoScrollProperty() { + return canAutoScroll; } } diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/Editor.java b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/Editor.java index 1f04ac122..245d2dad0 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/Editor.java +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/control/richtext/Editor.java @@ -16,10 +16,13 @@ import javafx.scene.text.Text; import org.fxmisc.flowless.Cell; import org.fxmisc.flowless.VirtualFlow; -import org.fxmisc.flowless.VirtualizedScrollPane; import org.fxmisc.richtext.CodeArea; import org.fxmisc.richtext.GenericStyledArea; -import org.fxmisc.richtext.model.*; +import org.fxmisc.richtext.model.PlainTextChange; +import org.fxmisc.richtext.model.ReadOnlyStyledDocument; +import org.fxmisc.richtext.model.StyleSpans; +import org.fxmisc.richtext.model.StyledDocument; +import org.fxmisc.richtext.model.TwoDimensional; import org.reactfx.Change; import org.reactfx.EventStream; import org.reactfx.EventStreams; @@ -72,8 +75,7 @@ public class Editor extends BorderPane { private static final StyleResult FALLBACK_STYLE_RESULT = new StyleResult(StyleSpans.singleton(Collections.emptyList(), 0), 0); private final StackPane stackPane = new StackPane(); private final CodeArea codeArea = new SafeCodeArea(); - private final ScrollBar horizontalScrollbar; - private final ScrollBar verticalScrollbar; + private final VirtualizedScrollPaneWrapper codeScrollWrapper; private final VirtualFlow virtualFlow; private final MemoizationList> virtualCellList; private final ExecutorService syntaxPool = ThreadPoolFactory.newSingleThreadExecutor("syntax-highlight"); @@ -92,9 +94,8 @@ public class Editor extends BorderPane { public Editor() { // Get the reflection hacks out of the way first. // - Want to have access to scrollbars & the internal 'virtualFlow' - VirtualizedScrollPaneWrapper scrollPane = new VirtualizedScrollPaneWrapper<>(codeArea); - horizontalScrollbar = Unchecked.get(() -> ReflectUtil.quietGet(scrollPane, VirtualizedScrollPane.class.getDeclaredField("hbar"))); - verticalScrollbar = Unchecked.get(() -> ReflectUtil.quietGet(scrollPane, VirtualizedScrollPane.class.getDeclaredField("vbar"))); + codeScrollWrapper = new VirtualizedScrollPaneWrapper<>(codeArea); + virtualFlow = Unchecked.get(() -> ReflectUtil.quietGet(codeArea, GenericStyledArea.class.getDeclaredField("virtualFlow"))); Object virtualCellManager = Unchecked.get(() -> ReflectUtil.quietGet(virtualFlow, VirtualFlow.class.getDeclaredField("cellListManager"))); virtualCellList = ReflectUtil.quietInvoke(virtualCellManager.getClass(), virtualCellManager, "getLazyCellList", new Class[0], new Object[0]); @@ -102,7 +103,7 @@ public Editor() { // Initial layout / style. getStylesheets().add("/style/code-editor.css"); setCenter(stackPane); - stackPane.getChildren().add(scrollPane); + stackPane.getChildren().add(codeScrollWrapper); // Do not want text wrapping in a code editor. codeArea.setWrapText(false); @@ -643,7 +644,7 @@ public boolean isParagraphVisible(int line) { */ @Nonnull public ScrollBar getHorizontalScrollbar() { - return horizontalScrollbar; + return codeScrollWrapper.getHorizontalScrollbar(); } /** @@ -651,7 +652,7 @@ public ScrollBar getHorizontalScrollbar() { */ @Nonnull public ScrollBar getVerticalScrollbar() { - return verticalScrollbar; + return codeScrollWrapper.getVerticalScrollbar(); } /** diff --git a/recaf-ui/src/main/java/software/coley/recaf/util/NodeEvents.java b/recaf-ui/src/main/java/software/coley/recaf/util/NodeEvents.java index d9a016f77..bdfd802d0 100644 --- a/recaf-ui/src/main/java/software/coley/recaf/util/NodeEvents.java +++ b/recaf-ui/src/main/java/software/coley/recaf/util/NodeEvents.java @@ -115,6 +115,17 @@ public static void addMouseMoveHandler(@Nonnull Node node, @Nonnull EventHandler addHandler(node, handler, Unchecked.cast(original), Node::setOnMouseMoved); } + /** + * @param node + * Node to add to. + * @param handler + * Handler to add. + */ + public static void addMouseDraggedHandler(@Nonnull Node node, @Nonnull EventHandler handler) { + Function> original = Node::getOnMouseDragged; + addHandler(node, handler, Unchecked.cast(original), Node::setOnMouseDragged); + } + /** * @param node * Node to add to.