diff --git a/src/main/java/edu/rpi/legup/controller/TreeController.java b/src/main/java/edu/rpi/legup/controller/TreeController.java index 36c1a9b68..53ab32b68 100644 --- a/src/main/java/edu/rpi/legup/controller/TreeController.java +++ b/src/main/java/edu/rpi/legup/controller/TreeController.java @@ -1,186 +1,99 @@ package edu.rpi.legup.controller; -import static edu.rpi.legup.app.GameBoardFacade.getInstance; - import edu.rpi.legup.model.Puzzle; -import edu.rpi.legup.model.tree.Tree; -import edu.rpi.legup.ui.boardview.BoardView; -import edu.rpi.legup.ui.proofeditorui.treeview.*; -import java.awt.*; -import java.awt.event.MouseEvent; -import java.awt.event.MouseWheelEvent; -import javax.swing.*; +import edu.rpi.legup.ui.proofeditorui.treeview.TreeElementView; +import edu.rpi.legup.ui.proofeditorui.treeview.TreePanel; +import edu.rpi.legup.ui.proofeditorui.treeview.TreeView; +import edu.rpi.legup.ui.proofeditorui.treeview.TreeViewSelection; +import org.jetbrains.annotations.NotNull; + +import static edu.rpi.legup.app.GameBoardFacade.getInstance; /** - * The TreeController class handles UI events from a TreePanel. It extends the Controller class to - * provide specific behavior for tree interactions + * The {@code TreeController} class handles UI events from a {@code TreePanel}. It extends the + * {@code Controller} class to provide specific behavior for tree interactions. */ -public class TreeController extends Controller { - /** - * TreeController Constructor creates a controller object to listen to ui events from a {@link - * TreePanel} - */ - public TreeController() {} +public class TreeController extends ZoomViewController { - /** - * Mouse Clicked event no default action - * - * @param e MouseEvent object - */ - @Override - public void mouseClicked(MouseEvent e) {} - - /** - * Mouse Pressed event sets the cursor to the move cursor and stores info for possible panning - * - * @param e MouseEvent object - */ - @Override - public void mousePressed(MouseEvent e) { - super.mousePressed(e); - } + /** {@code TreeController} constructor creates a controller object to listen to ui events from a {@link TreePanel}. */ + public TreeController() {} - /** - * Mouse Released event sets the cursor back to the default cursor and reset info for panning - * Set board modifiability - * - * @param e MouseEvent object - */ @Override - public void mouseReleased(MouseEvent e) { + public void mouseReleased(@NotNull ControllerMouseEvent e) { super.mouseReleased(e); - TreeView treeView = (TreeView) viewer; - Point point = treeView.getActualPoint(e.getPoint()); - TreeElementView treeElementView = treeView.getTreeElementView(point); + TreeView treeView = (TreeView) getView(); + TreeElementView treeElementView = treeView.getTreeElementView(e.getPrecisePoint()); Puzzle puzzle = getInstance().getPuzzleModule(); TreeViewSelection selection = treeView.getSelection(); + if (treeElementView != null) { if (e.isShiftDown()) { selection.addToSelection(treeElementView); + } else if (e.isControlDown()) { + selection.toggleSelection(treeElementView); } else { - if (e.isControlDown()) { - if (!(selection.getSelectedViews().size() == 1 - && treeElementView == selection.getFirstSelection())) { - selection.toggleSelection(treeElementView); - } - } else { - selection.newSelection(treeElementView); - } + selection.newSelection(treeElementView); } - puzzle.notifyTreeListeners(listener -> listener.onTreeSelectionChanged(selection)); - puzzle.notifyBoardListeners( - listener -> listener.onTreeElementChanged(treeElementView.getTreeElement())); - } - } - /** - * Mouse Entered event no default action - * - * @param e MouseEvent object - */ - @Override - public void mouseEntered(MouseEvent e) { - TreeView treeView = (TreeView) viewer; - Point point = treeView.getActualPoint(e.getPoint()); - Tree tree = getInstance().getTree(); - BoardView boardView = getInstance().getLegupUI().getBoardView(); - TreeElementView treeElementView = treeView.getTreeElementView(point); - Puzzle puzzle = getInstance().getPuzzleModule(); - if (treeElementView != null) { + puzzle.notifyTreeListeners(listener -> listener.onTreeSelectionChanged(selection)); puzzle.notifyBoardListeners( listener -> listener.onTreeElementChanged(treeElementView.getTreeElement())); } } - /** - * Mouse Exited event no default action - * - * @param e MouseEvent object - */ @Override - public void mouseExited(MouseEvent e) { - TreeView treeView = (TreeView) viewer; - Point point = treeView.getActualPoint(e.getPoint()); - TreeElementView elementView = treeView.getTreeElementView(point); - Puzzle puzzle = getInstance().getPuzzleModule(); + public void mouseExited(@NotNull ControllerMouseEvent e) { + TreeView treeView = (TreeView) getView(); TreeViewSelection selection = treeView.getSelection(); - selection.setMousePoint(null); - if (elementView != null) { - TreeElementView selectedView = selection.getFirstSelection(); - puzzle.notifyBoardListeners( - listener -> listener.onTreeElementChanged(selectedView.getTreeElement())); - } + selection.clearHover(); } - /** - * Mouse Dragged event adjusts the viewport - * - * @param e MouseEvent object - */ @Override - public void mouseDragged(MouseEvent e) { - TreeView treeView = (TreeView) viewer; - Point point = treeView.getActualPoint(e.getPoint()); - TreeElementView treeElementView = treeView.getTreeElementView(point); + public void mouseDragged(@NotNull ControllerMouseEvent e) { + TreeView treeView = (TreeView) getView(); + TreeElementView treeElementView = treeView.getTreeElementView(e.getPrecisePoint()); Puzzle puzzle = getInstance().getPuzzleModule(); - TreeViewSelection selection = treeView.getSelection(); + if (puzzle != null) { - selection.setMousePoint(treeView.getActualPoint(e.getPoint())); + TreeViewSelection selection = treeView.getSelection(); + selection.setMousePoint(e.getPrecisePoint()); + if (treeElementView != null && treeElementView != selection.getHover()) { puzzle.notifyBoardListeners( - listener -> - listener.onTreeElementChanged(treeElementView.getTreeElement())); + listener -> listener.onTreeElementChanged(treeElementView.getTreeElement())); selection.newSelection(treeElementView); selection.newHover(treeElementView); puzzle.notifyTreeListeners(listener -> listener.onTreeSelectionChanged(selection)); - } else { - if (treeElementView == null && selection.getHover() != null) { + } + else if (treeElementView == null && selection.getHover() != null) { puzzle.notifyBoardListeners( listener -> - listener.onTreeElementChanged( - selection.getFirstSelection().getTreeElement())); + listener.onTreeElementChanged(selection.getFirstSelection().getTreeElement())); selection.clearHover(); - } } } } - /** - * Mouse Moved event no default action - * - * @param e MouseEvent object - */ @Override - public void mouseMoved(MouseEvent e) { - TreeView treeView = (TreeView) viewer; - Point point = treeView.getActualPoint(e.getPoint()); - TreeElementView treeElementView = treeView.getTreeElementView(point); + public void mouseMoved(@NotNull ControllerMouseEvent e) { + TreeView treeView = (TreeView) getView(); + TreeElementView treeElementView = treeView.getTreeElementView(e.getPrecisePoint()); Puzzle puzzle = getInstance().getPuzzleModule(); + if (puzzle != null) { TreeViewSelection selection = treeView.getSelection(); - selection.setMousePoint(treeView.getActualPoint(e.getPoint())); + selection.setMousePoint(e.getPrecisePoint()); + if (treeElementView != null && treeElementView != selection.getHover()) { selection.newHover(treeElementView); puzzle.notifyTreeListeners(listener -> listener.onTreeSelectionChanged(selection)); - } else { - if (treeElementView == null && selection.getHover() != null) { - selection.clearHover(); - puzzle.notifyTreeListeners( - listener -> listener.onTreeSelectionChanged(selection)); - } + } + else if (treeElementView == null && selection.getHover() != null) { + selection.clearHover(); + puzzle.notifyTreeListeners(listener -> listener.onTreeSelectionChanged(selection)); } } } - - /** - * Mouse Wheel Moved event zooms in on the viewport - * - * @param e MouseEvent object - */ - @Override - public void mouseWheelMoved(MouseWheelEvent e) { - super.viewer.scroll(e.getWheelRotation()); - } -} +} \ No newline at end of file diff --git a/src/main/java/edu/rpi/legup/controller/ZoomViewController.java b/src/main/java/edu/rpi/legup/controller/ZoomViewController.java new file mode 100644 index 000000000..e3647ccab --- /dev/null +++ b/src/main/java/edu/rpi/legup/controller/ZoomViewController.java @@ -0,0 +1,173 @@ +package edu.rpi.legup.controller; + +import edu.rpi.legup.ui.zoompane.ZoomView; +import org.intellij.lang.annotations.MagicConstant; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.awt.*; +import java.awt.event.*; +import java.awt.geom.Point2D; + +/** + * {@code ZoomViewController} is an abstract class designed to handle various mouse and key events to provide + * interactability for a {@code ZoomView}. + */ +public abstract class ZoomViewController implements KeyListener, ActionListener { + + /** The view that this controller is installed on. */ + private ZoomView view; + + /** Set the {@code ZoomView} that this controller is installed in. */ + public void setView(@Nullable ZoomView newView) { view = newView; } + + /** @return the {@code ZoomView} that this controller is installed in. */ + public ZoomView getView() { return view; } + + @Override + public void actionPerformed(@NotNull ActionEvent e) {} + + @Override + public void keyTyped(@NotNull KeyEvent e) {} + + @Override + public void keyPressed(@NotNull KeyEvent e) {} + + @Override + public void keyReleased(@NotNull KeyEvent e) {} + + /** + * Invoked when the mouse button has been clicked (pressed and released) on a component. + * @param e the event to be processed + */ + public void mouseClicked(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when a mouse button has been pressed on a component. + * @param e the event to be processed + */ + public void mousePressed(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when a mouse button has been released on a component. + * @param e the event to be processed + */ + public void mouseReleased(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when the mouse enters a component. + * @param e the event to be processed + */ + public void mouseEntered(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when the mouse exits a component. + * @param e the event to be processed + */ + public void mouseExited(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when a mouse button is pressed on a component and then dragged. {@code MOUSE_DRAGGED} events + * will continue to be delivered to the component where the drag originated until the mouse button is + * released (regardless of whether the mouse position is within the bounds of the component). + *
+ * Due to platform-dependent Drag&Drop implementations, {@code MOUSE_DRAGGED} events may not be + * delivered during a native Drag&Drop operation. + * + * @param e the event to be processed + */ + public void mouseDragged(@NotNull ControllerMouseEvent e) {} + + /** + * Invoked when the mouse cursor has been moved onto a component but no buttons have been pushed. + * @param e the event to be processed + */ + public void mouseMoved(@NotNull ControllerMouseEvent e) {} + + /** A {@code MouseEvent} that maintains a {@code Point2D} location to support higher precision. */ + public static class ControllerMouseEvent extends MouseEvent { + + /** High precision location of the event. */ + private final Point2D precisePoint; + + /** + * Constructs a {@code ControllerMouseEvent} object with the specified source component, type, time, + * modifiers, coordinates, click count, popupTrigger flag, and button number. + *
+ * Creating an invalid event (such as by using more than one of the old _MASKs, or modifier/button + * values which don't match) results in unspecified behavior. + * + * @param source The {@code Component} that originated the event + * @param id An integer indicating the type of event. + * For information on allowable values, see the class description for {@link MouseEvent} + * @param when A long integer that gives the time the event occurred. + * Passing negative or zero value is not recommended + * @param modifiers a modifier mask describing the modifier keys and mouse buttons (for example, shift, + * ctrl, alt, and meta) that are down during the event. Only extended modifiers are + * allowed to be used as a value for this parameter (see the + * {@link InputEvent#getModifiersEx} class for the description of extended modifiers). + * Passing negative parameter is not recommended. + * Zero value means that no modifiers were passed + * @param where The variable precision point for the mouse location. + * @param clickCount The number of mouse clicks associated with event. + * Passing negative value is not recommended + * @param popupTrigger A boolean that equals {@code true} if this event is a trigger for a popup menu + * @param button An integer that indicates, which of the mouse buttons has changed its state. + * The following rules are applied to this parameter: + *
+ * If this transition is a merge, this operation will do nothing if any of {@code TreeNode} parents' views
+ * have not yet been created. It will also take the additional step of assigning the new
+ * {@code TreeTransitionView} to be the layout child of the deepest common ancestor of all of its parent
+ * node views.
*
- * @param trans The {@link TreeTransition} to be added
+ * @param transition The {@code TreeTransition} to be added
*/
- private void addTreeTransition(TreeTransition trans) {
- List
+ * Due to AWT and Swing limitations, only {@link ZoomView} and its subclasses can be used as the view of
+ * a {@code ZoomPane}, whereas {@link JScrollPane} and {@link JViewport} typically display {@link Component}s
+ * and {@link JComponent}s. {@code ZoomPane} and {@link ZoomViewport} are still subclasses of
+ * those classes despite these fundamental differences to preserve behavioral and styling consistency.
+ *
+ * Note: if this component's performance impact is of concern, smooth scrolling can be disabled
+ * by setting the {@code ZoomPane.smoothScrolling} client property or UI default to false.
+ *
+ * @see JScrollPane
+ * @see ZoomViewport
+ * @see ZoomView
+ */
+public class ZoomPane extends JScrollPane implements ZoomPaneConstants {
+
+ private static final String uiClassID = "ZoomPaneUI";
+
+ /**
+ * The display policy for the zoombar. The default is {@code ZoomPaneConstants.ZOOMBAR_ALWAYS}.
+ * @see #setZoomBarPolicy
+ */
+ protected int zoomBarPolicy = ZOOMBAR_ALWAYS;
+
+ /** The zoom pane's zoom bar child. Default is a {@code ZoomBar}. */
+ protected ZoomBar zoomBar;
+
+ // ---------- Zoom behavior settings ----------
+
+ /** Zooming sensitivity: {@code (1 / # steps to cover entire zoom range)}. */
+ protected double sensitivity = 0.1;
+
+ /** The minimum scale factor that can be applied to the viewport's view. */
+ protected double minScaleFactor = 1.0 / 3.0;
+
+ /**
+ * Flag for whether the minimum scale factor is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively longest side.
+ */
+ protected boolean minScaleRelative = true;
+
+ /** The maximum scale factor that can be applied to the viewport's view. */
+ protected double maxScaleFactor = 5.0;
+
+ /**
+ * Flag for whether the maximum scale factor is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively shortest side.
+ */
+ protected boolean maxScaleRelative = false;
+
+ /**
+ * Creates a {@code ZoomPane} that displays the zoom view in a zoom viewport whose view
+ * position can be controlled with a pair of scrollbars and whose zoom level can be controlled
+ * with a zoom bar.
+ *
+ * @param view the zoomable view to display in the zoom pane's viewport
+ *
+ * @see #setViewportView(ZoomView)
+ */
+ public ZoomPane(@Nullable ZoomView view) {
+ setLayout(new ScrollPaneLayout.UIResource());
+ setVerticalScrollBarPolicy(VERTICAL_SCROLLBAR_ALWAYS);
+ setHorizontalScrollBarPolicy(HORIZONTAL_SCROLLBAR_ALWAYS);
+ setZoomBarPolicy(ZOOMBAR_ALWAYS);
+ setViewport((ZoomViewport) createViewport());
+ setVerticalScrollBar(createVerticalScrollBar());
+ setHorizontalScrollBar(createHorizontalScrollBar());
+ setZoomBar(createZoomBar());
+ if (view != null) { setViewportView(view); }
+ setOpaque(true);
+ updateUI();
+ }
+
+ /** Creates an empty (no view) {@code ZoomPane}. */
+ public ZoomPane() { this(null); }
+
+ /**
+ * Sets the {@code ZoomPaneUI} object that provides the look and feel (L&F) for this component.
+ *
+ * @param ui the {@code ZoomPaneUI} L&F object
+ * @see #getUI
+ */
+ public void setUI(@Nullable ZoomPaneUI ui) { super.setUI(ui); }
+
+ /**
+ * {@inheritDoc}
+ * @deprecated use {@link #setUI(ZoomPaneUI)} instead
+ *
+ * @param ui {@inheritDoc}
+ * @throws IllegalArgumentException if ui is not an instance of {@code ZoomPaneUI}
+ */
+ @Deprecated
+ @Override
+ public void setUI(@Nullable ScrollPaneUI ui) {
+ if (ui instanceof ZoomPaneUI) { setUI((ZoomPaneUI) ui); }
+ else { throw new IllegalArgumentException("ZoomPane's UI delegate must be an instance of ZoomPaneUI"); }
+ }
+
+ /**
+ * Replaces the current {@code ZoomPaneUI} object with a version from the current default look and feel.
+ * To be called when the default look and feel changes.
+ *
+ * @see JComponent#updateUI()
+ * @see UIManager#getUI(javax.swing.JComponent)
+ */
+ @Override
+ public void updateUI() { setUI((ZoomPaneUI) UIManager.getUI(this)); }
+
+ /**
+ * {@inheritDoc}
+ * @return the string "ZoomPaneUI"
+ * @see JScrollPane#getUIClassID()
+ */
+ @Override
+ public String getUIClassID() { return uiClassID; }
+
+ /**
+ * Determines when the vertical scrollbar appears in the {@code ZoomPane}.
+ * Legal values are:
+ *
+ * Warning: using {@code ZoomPaneConstants.VERTICAL_SCROLLBAR_AS_NEEDED} may result in jumps in
+ * view size and position as a result of creating space for the scroll bars.
+ *
+ * @param policy {@inheritDoc}
+ * @throws IllegalArgumentException {@inheritDoc}
+ * @see JScrollPane#setVerticalScrollBarPolicy(int)
+ */
+ @Override
+ public void setVerticalScrollBarPolicy(int policy) { super.setVerticalScrollBarPolicy(policy); }
+
+ /**
+ * Determines when the horizontal scrollbar appears in the {@code ZoomPane}.
+ * Legal values are:
+ *
+ * Warning: using {@code ZoomPaneConstants.HORIZONTAL_SCROLLBAR_AS_NEEDED} may result in jumps in
+ * view size and position as a result of creating space for the scroll bars.
+ *
+ * @param policy {@inheritDoc}
+ * @throws IllegalArgumentException {@inheritDoc}
+ * @see JScrollPane#setHorizontalScrollBarPolicy(int)
+ */
+ @Override
+ public void setHorizontalScrollBarPolicy(int policy) { super.setHorizontalScrollBarPolicy(policy); }
+
+ @Override
+ public Rectangle getViewportBorderBounds() {
+
+ Rectangle borderR = super.getViewportBorderBounds();
+ ZoomBar zb = getZoomBar();
+ if (zb != null && zb.isVisible()) { borderR.height -= zb.getHeight(); }
+
+ return borderR;
+ }
+
+ /** Scroll bar implementing {@code UIResource}. */
+ private static class ScrollBarUIResource extends JScrollBar implements UIResource {
+
+ public ScrollBarUIResource(@MagicConstant(intValues =
+ {Adjustable.HORIZONTAL, Adjustable.VERTICAL}) int policy) {
+ super(policy);
+ }
+ }
+
+ /**
+ * Returns a new {@code JScrollBar} by default. Subclasses may override this method to force
+ * {@code ZoomPaneUI} implementations to use a {@code JScrollBar} subclass. Used by
+ * {@code ZoomPaneUI} implementations to create the vertical scrollbar.
+ *
+ * @return {@inheritDoc}
+ * @see JScrollBar
+ */
+ @Override
+ public JScrollBar createVerticalScrollBar() { return new ScrollBarUIResource(JScrollBar.VERTICAL); }
+
+ /**
+ * Returns a new {@code JScrollBar} by default. Subclasses may override this method to force
+ * {@code ZoomPaneUI} implementations to use a {@code JScrollBar} subclass. Used by
+ * {@code ZoomPaneUI} implementations to create the horizontal scrollbar.
+ *
+ * @return {@inheritDoc}
+ * @see JScrollBar
+ */
+ @Override
+ public JScrollBar createHorizontalScrollBar() { return new ScrollBarUIResource(JScrollBar.HORIZONTAL); }
+
+ /**
+ * Returns the zoom bar policy value.
+ *
+ * @return the {@code zoomBarPolicy} property
+ * @see #setZoomBarPolicy(int)
+ */
+ public int getZoomBarPolicy() { return zoomBarPolicy; }
+
+ /**
+ * Determines when the zoombar appears in the zoompane. The options are:
+ *
+ * Most applications will find it more convenient to use {@link #setViewportView(ZoomView)} to add a viewport
+ * and a view to the zoompane.
+ *
+ * @param viewport the new viewport to be used; if viewport is {@code null}, the old viewport is still
+ * removed and the new viewport is set to {@code null}
+ * @see #createViewport()
+ * @see #getViewport()
+ * @see #setViewportView(ZoomView)
+ */
+ public void setViewport(@Nullable ZoomViewport viewport) { super.setViewport(viewport); }
+
+ /**
+ * Removes the old viewport (if there is one); syncs the scroll bars and zoom bar with the new viewport.
+ *
+ * Most applications will find it more convenient to use {@link #setViewportView(ZoomView)} to add a viewport
+ * and a view to the zoompane.
+ * @deprecated use {@link #setViewport(ZoomViewport)} instead
+ *
+ * @param viewport the new viewport to be used; if viewport is {@code null}, the old viewport is still
+ * removed and the new viewport is set to {@code null}
+ * @throws IllegalArgumentException if viewport is not an instance of {@code ZoomViewport} or {@code null}
+ * @see #createViewport()
+ * @see #getViewport()
+ * @see #setViewportView(ZoomView)
+ */
+ @Deprecated
+ @Override
+ public void setViewport(@Nullable JViewport viewport) {
+ if (viewport instanceof ZoomViewport || viewport == null) { setViewport((ZoomViewport) viewport); }
+ else { throw new IllegalArgumentException("ZoomPane's viewport must be an instance of ZoomViewport"); }
+ }
+
+ /**
+ * Returns the current {@code ZoomViewport}.
+ *
+ * @return the viewport property
+ * @see #setViewport(ZoomViewport)
+ */
+ public ZoomViewport getZoomViewport() { return (ZoomViewport) getViewport(); }
+
+ /**
+ * Creates a viewport if necessary and then sets its view. Applications that don't provide the view
+ * directly to the {@code ZoomPane} constructor should use this method to specify the {@code ZoomView}
+ * child that's going to be displayed in the {@code ZoomPane}. For example:
+ *
+ * Note: this method considers one block scroll to be the extent size of the viewport minus
+ * the area defined by the {@code viewPadding} property, not the entire extent size of the viewport.
+ *
+ * @param zoomPane the {@code ZoomPane} performing the scroll
+ * @param orientation axis along which to scroll: either {@code SwingConstants.VERTICAL} or
+ * {@code SwingConstants.HORIZONTAL}
+ * @param direction signed direction in which to scroll along the axis
+ * @param block flag for whether to do a block scroll or a unit scroll
+ */
+ protected static void scroll(@NotNull ZoomPane zoomPane, int orientation, double direction, boolean block) {
+ if (!viewportViewReady(zoomPane)) { return; }
+
+ ((ZoomPaneUI) zoomPane.getUI()).isFit = false;
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ Rectangle2D visRect = viewport.getViewRect();
+ Insets padding = getViewPadding(zoomPane);
+ double scaleFactor = viewport.getScaleFactor();
+ boolean vertical = orientation == SwingConstants.VERTICAL;
+ double scrollAmount;
+
+ scrollAmount = (vertical) ?
+ visRect.getHeight() - (padding.top + padding.bottom) / scaleFactor :
+ visRect.getWidth() - (padding.left + padding.right) / scaleFactor;
+ if (!block) { scrollAmount /= 4.0; }
+
+ Point2D viewPos = new Point2D.Double(
+ visRect.getX() + (vertical ? 0 : scrollAmount * direction),
+ visRect.getY() + (vertical ? scrollAmount * direction : 0)
+ );
+ viewport.setViewPosition(clampViewPos(zoomPane, viewPos));
+ }
+
+ /**
+ * Zoom the view to a specified zoom level {@code [0, 1]} about a focus point.
+ *
+ * @param zoomPane the {@code ZoomPane} performing the zoom
+ * @param zoom the zoom level to set
+ * @param focus the focus point to zoom about in view coordinates. If {@code null}, it will be set to
+ * the center of the current display state
+ */
+ protected static void zoomTo(@NotNull ZoomPane zoomPane, double zoom, @Nullable Point2D focus) {
+ if (!viewportViewReady(zoomPane)) { return; }
+
+ ((ZoomPaneUI) zoomPane.getUI()).isFit = false;
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ double oldScaleFactor = viewport.getScaleFactor();
+ double newScaleFactor = zoomToScaleFactor(zoomPane, Math.clamp(zoom, 0, 1));
+ if (newScaleFactor != oldScaleFactor) {
+
+ Rectangle2D viewRect = viewport.getPreciseViewRect();
+ if (focus == null) { focus = new Point2D.Double(viewRect.getCenterX(), viewRect.getCenterY()); }
+
+ viewport.setScaleFactor(newScaleFactor);
+
+ double scaleRatio = newScaleFactor / oldScaleFactor;
+ viewport.setViewPosition(clampViewPos(zoomPane, new Point2D.Double(
+ focus.getX() - (focus.getX() - viewRect.getX()) / scaleRatio,
+ focus.getY() - (focus.getY() - viewRect.getY()) / scaleRatio
+ )));
+ }
+ }
+
+ /**
+ * Zoom into or out of the view by some amount. Since zoom level operates on a {@code double}-precision
+ * {@code [0, 1]} scale, an {@code amount >= 1} will zoom all the way in and an {@code amount <= -1}
+ * will zoom all the way out.
+ *
+ * Note: this function does not take sensitivity into account. For an equivalent zooming method with
+ * sensitivity, use {@link #zoom(ZoomPane, double, Point2D)}.
+ *
+ * @param zoomPane the {@code ZoomPane} performing the zoom
+ * @param amount the amount {@code [-1, 1]} to zoom.
+ * @param focus the focus point to zoom about in view coordinates.
+ */
+ public static void zoomBy(@NotNull ZoomPane zoomPane, double amount, @Nullable Point2D focus) {
+ if (!viewportViewReady(zoomPane)) { return; }
+
+ double currentZoom = scaleFactorToZoom(zoomPane, ((ZoomViewport) zoomPane.getViewport()).getScaleFactor());
+ double newZoom = Math.clamp(currentZoom + amount, 0, 1);
+ if (newZoom != currentZoom) { zoomTo(zoomPane, newZoom, focus); }
+ }
+
+ /**
+ * Zoom into or out of the view by some number of "steps" or wheel ticks.
+ *
+ * @param zoomPane the {@code ZoomPane} performing the zoom
+ * @param steps the number of steps to zoom by. Zoom in if {@code steps > 0},
+ * zoom out if {@code steps < 0}, unless the sensitivity is negative.
+ * @param focus the focus point to zoom about in view coordinates.
+ */
+ public static void zoom(@NotNull ZoomPane zoomPane, double steps, @Nullable Point2D focus) {
+ if (!viewportViewReady(zoomPane)) { return; }
+ if (steps != 0) { zoomBy(zoomPane, steps * zoomPane.getSensitivity(), focus); }
+ }
+
+ /**
+ * Zoom the view to fit the viewport on its relatively longest side.
+ *
+ * @param zoomPane the {@code ZoomPane} performing the fit
+ */
+ public static void zoomToFit(@NotNull ZoomPane zoomPane) {
+ if (!readyToFit(zoomPane)) { return; }
+
+ ((ZoomPaneUI) zoomPane.getUI()).isFit = true;
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ Insets padding = getViewPadding(zoomPane);
+ double scaleFactor = clampScaleFactor(zoomPane, Math.min(
+ (viewport.getWidth() - padding.left - padding.right) / viewport.getViewSize().getWidth(),
+ (viewport.getHeight() - padding.top - padding.bottom) / viewport.getViewSize().getHeight()
+ ));
+
+ viewport.setScaleFactor(scaleFactor);
+ viewport.setViewPosition(clampViewPos(zoomPane,
+ new Point2D.Double(-padding.left / scaleFactor, -padding.top / scaleFactor)));
+ }
+
+ // ---------------------------
+ // Helpers
+ // ---------------------------
+
+ /** @return {@code true} if the viewport view is ready to be manipulated, {@code false} otherwise */
+ protected static boolean viewportViewReady(@NotNull ZoomPane zoomPane) {
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ if (viewport == null || viewport.getZoomView() == null) { return false; }
+ Dimension viewSize = viewport.getViewSize();
+ return viewSize.width > 0 && viewSize.height > 0;
+ }
+
+ /** @return {@code true} if viewport is ready to fit its view, {@code false} otherwise. */
+ protected static boolean readyToFit(@NotNull ZoomPane zoomPane) {
+ JViewport viewport = zoomPane.getViewport();
+ return viewportViewReady(zoomPane)
+ && viewport.isValid() && viewport.getWidth() > 0 && viewport.getHeight() > 0;
+ }
+
+ /**
+ * Convert a scale factor value to a zoom level based on the {@code ZoomPane}'s zoom limits.
+ *
+ * @param zoomPane the zoom pane doing the conversion
+ * @param scaleFactor the scale factor to be converted
+ * @return the zoom level of {@code scaleFactor} in {@code zoomPane}
+ */
+ protected static double scaleFactorToZoom(@NotNull ZoomPane zoomPane, double scaleFactor) {
+ double min = getMinScaleFactor(zoomPane);
+ double max = getMaxScaleFactor(zoomPane);
+ if (max == min) { return 0; } // Avoid divide by 0
+ return Math.log(scaleFactor / min) / Math.log(max / min);
+ }
+
+ /**
+ * Convert a zoom level to a scale factor value based on the {@code ZoomPane}'s zoom limits.
+ *
+ * @param zoomPane the zoom pane doing the conversion
+ * @param zoom the zoom level to be converted
+ * @return the scale factor value of {@code zoom} in {@code zoomPane}
+ */
+ protected static double zoomToScaleFactor(@NotNull ZoomPane zoomPane, double zoom) {
+ double min = getMinScaleFactor(zoomPane);
+ double max = getMaxScaleFactor(zoomPane);
+ return min * Math.pow(max / min, zoom);
+ }
+
+ /** @return the minimum scale factor value of some {@code ZoomPane} */
+ protected static double getMinScaleFactor(@NotNull ZoomPane zoomPane) {
+ double minScaleFactor = zoomPane.getMinScaleFactor();
+ if (!viewportViewReady(zoomPane) || !zoomPane.isMinScaleRelative()) { return minScaleFactor; }
+
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ return minScaleFactor * Math.min(
+ viewport.getWidth() / viewport.getViewSize().getWidth(),
+ viewport.getHeight() / viewport.getViewSize().getHeight()
+ );
+ }
+
+ /** @return the maximum scale factor value of some {@code ZoomPane} */
+ protected static double getMaxScaleFactor(@NotNull ZoomPane zoomPane) {
+ double maxScaleFactor = zoomPane.getMaxScaleFactor();
+ if (!viewportViewReady(zoomPane) || zoomPane.isMaxScaleRelative()) { return maxScaleFactor; }
+
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ return maxScaleFactor * Math.max(
+ viewport.getWidth() / viewport.getViewSize().getWidth(),
+ viewport.getHeight() / viewport.getViewSize().getHeight()
+ );
+ }
+
+ /** @return {@code scaleFactor} clamped to the zoom limits of some {@code ZoomPane} */
+ protected static double clampScaleFactor(@NotNull ZoomPane zoomPane, double scaleFactor) {
+ double min = getMinScaleFactor(zoomPane);
+ double max = getMaxScaleFactor(zoomPane);
+ return (min < max) ? Math.clamp(scaleFactor, min, max) : (min + max) / 2.0;
+ }
+
+ /** @return {@code viewPos} clamped to the valid view position space of some {@code ZoomPane} */
+ protected static Point2D clampViewPos(@NotNull ZoomPane zoomPane, @NotNull Point2D viewPos) {
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ Dimension viewSize = viewport.getViewSize();
+ Dimension2D extentSize = viewport.getPreciseExtentSize();
+ Insets padding = getViewPadding(zoomPane);
+ double scaleFactor = viewport.getScaleFactor();
+
+ double minX = -padding.left / scaleFactor;
+ double minY = -padding.top / scaleFactor;
+ double maxX = viewSize.width - extentSize.getWidth() + padding.right / scaleFactor;
+ double maxY = viewSize.height - extentSize.getHeight() + padding.bottom / scaleFactor;
+
+ viewPos.setLocation(
+ (minX < maxX) ?
+ Math.clamp(viewPos.getX(), minX, maxX) :
+ (minX + maxX) / 2,
+ (minY < maxY) ?
+ Math.clamp(viewPos.getY(), minY, maxY) :
+ (minY + maxY) / 2
+ );
+ return viewPos;
+ }
+ }
+
+ /** The handler for all AWT events this UI needs to process. */
+ private class Handler implements ActionListener, ChangeListener, MouseWheelListener,
+ PropertyChangeListener, ComponentListener {
+
+ // Used by componentResized to remember old viewport size
+ private Dimension oldSize = new Dimension();
+
+ // MouseWheelListener: This is installed on the ZoomViewport.
+ @Override
+ public void mouseWheelMoved(@NotNull MouseWheelEvent e) {
+ if (scrollpane.isWheelScrollingEnabled() && Actions.viewportViewReady((ZoomPane) scrollpane)
+ && ((ZoomPane) scrollpane).getSensitivity() != 0) {
+ ZoomViewport viewport = (ZoomViewport) scrollpane.getViewport();
+
+ double rotation = e.getWheelRotation();
+ if (isSmoothScrollingEnabled()) { rotation = e.getPreciseWheelRotation(); }
+
+ if (rotation == 0) { return; }
+ e.consume();
+
+ if (e.getScrollType() == MouseWheelEvent.WHEEL_BLOCK_SCROLL) { rotation *= 2.5; }
+ Actions.zoom((ZoomPane) scrollpane, -rotation, viewport.toViewCoordinates((Point2D) e.getPoint()));
+
+ viewport.getPeer().dispatchEvent(new MouseEvent(
+ viewport.getPeer(), MouseEvent.MOUSE_MOVED, e.getWhen(), 0,
+ e.getX(), e.getY(), 0, false
+ ));
+ }
+ }
+
+ // ChangeListener: This in installed on the ZoomBar and both ScrollBar models.
+ @Override
+ public void stateChanged(@NotNull ChangeEvent e) {
+
+ if (scrollpane.getViewport() != null && !ignoreStateChanges) {
+
+ if (e.getSource() == scrollpane.getViewport()) { syncScrollPaneWithViewport(); }
+
+ else {
+ JScrollBar hsb = scrollpane.getHorizontalScrollBar();
+ if (hsb != null && e.getSource() == hsb.getModel()) { sbStateChanged(e, false); }
+
+ else {
+ JScrollBar vsb = scrollpane.getVerticalScrollBar();
+ if (vsb != null && e.getSource() == vsb.getModel()) { sbStateChanged(e, true); }
+
+ else {
+ ZoomBar zb = ((ZoomPane) scrollpane).getZoomBar();
+ if (zb != null && e.getSource() == zb.getModel()) { zbStateChanged(e); }
+ }
+ }
+ }
+ }
+ }
+
+ private void sbStateChanged(@NotNull ChangeEvent e, boolean isVertical) {
+ ZoomViewport viewport = (ZoomViewport) scrollpane.getViewport();
+ BoundedRangeModel model = (BoundedRangeModel) e.getSource();
+ Dimension2D extentSize = viewport.getPreciseExtentSize();
+ Dimension viewSize = viewport.getViewSize();
+ Point2D p = viewport.getPreciseViewPosition();
+ Insets padding = getViewPadding((ZoomPane) scrollpane);
+ double scaleFactor = viewport.getScaleFactor();
+
+ double normValue = model.getExtent() == model.getMaximum() - model.getMinimum() ? 0 :
+ (double) (model.getValue() - model.getMinimum()) /
+ (model.getMaximum() - model.getExtent() - model.getMinimum());
+ double viewMin, viewMax;
+
+ if (isVertical) {
+ viewMin = -padding.top / scaleFactor;
+ viewMax = viewSize.height - extentSize.getHeight() + padding.bottom / scaleFactor;
+ }
+ else {
+ viewMin = -padding.left / scaleFactor;
+ viewMax = viewSize.width - extentSize.getWidth() + padding.right / scaleFactor;
+ }
+ double viewValue = viewMin + normValue * (viewMax - viewMin);
+ p.setLocation(isVertical ? p.getX() : viewValue, isVertical ? viewValue : p.getY());
+
+ ignoreStateChanges = true;
+ viewport.setViewPosition(Actions.clampViewPos((ZoomPane) scrollpane, p));
+ ignoreStateChanges = false;
+ }
+
+ private void zbStateChanged(@NotNull ChangeEvent e) {
+ BoundedRangeModel model = (BoundedRangeModel) e.getSource();
+ double min = model.getMinimum();
+ double max = model.getMaximum();
+ double value = model.getValue();
+ // Can't ignore state changes because scrollbars may need to be updated;
+ // viewport sync will ignore for us
+ Actions.zoomTo((ZoomPane) scrollpane, (value - min) / (max - min), null);
+ }
+
+ // ActionListener: This is installed on the ZoomBar fit button.
+ @Override
+ public void actionPerformed(@NotNull ActionEvent e) { Actions.zoomToFit((ZoomPane) scrollpane); }
+
+ // PropertyChangeListener: this is installed on the ZoomPane and ZoomBar.
+ @Override
+ public void propertyChange(@NotNull PropertyChangeEvent e) {
+
+ if (e.getSource() == scrollpane) {
+ if (e.getPropertyName().equals("zoomBar")) { updateZoomBar(e); }
+ }
+ else if (e.getSource() == scrollpane.getViewport()) { vpPropertyChange(e); }
+ else if (e.getSource() == ((ZoomPane) scrollpane).getZoomBar()) { zbPropertyChange(e); }
+ }
+
+ private void vpPropertyChange(@NotNull PropertyChangeEvent e) {
+ if (e.getPropertyName().equals("view")) {
+ if (e.getNewValue() != null) {
+ if (Actions.readyToFit((ZoomPane) scrollpane)) {
+ Actions.zoomToFit((ZoomPane) scrollpane);
+ waitingToFit = false;
+ }
+ else { waitingToFit = true; }
+ }
+ }
+ }
+
+ private void zbPropertyChange(@NotNull PropertyChangeEvent e) {
+
+ if (e.getPropertyName().equals("model")) {
+
+ BoundedRangeModel oldModel = (BoundedRangeModel) e.getOldValue();
+ BoundedRangeModel newModel = (BoundedRangeModel) e.getNewValue();
+
+ if (oldModel != null) { oldModel.removeChangeListener(zbChangeListener); }
+ if (newModel != null) { newModel.addChangeListener(zbChangeListener); }
+ }
+ else if (e.getPropertyName().equals("fitButton")) {
+
+ JButton oldButton = (JButton) e.getOldValue();
+ JButton newButton = (JButton) e.getNewValue();
+
+ if (oldButton != null) { oldButton.removeActionListener(zbFitListener); }
+ if (newButton != null) { newButton.addActionListener(zbFitListener); }
+ }
+ }
+
+ // ComponentListener: this is installed on the ZoomViewport.
+ @Override
+ public void componentResized(@NotNull ComponentEvent e) {
+ ZoomViewport viewport = (ZoomViewport) scrollpane.getViewport();
+ if (e.getComponent() == viewport) {
+
+ // If viewport has been resized from 0, may need to fit the view
+ if (waitingToFit && Actions.readyToFit((ZoomPane) scrollpane)) {
+ Actions.zoomToFit((ZoomPane) scrollpane);
+ waitingToFit = false;
+ }
+
+ Dimension newSize = e.getComponent().getSize();
+ if (newSize.width > 0 && newSize.height > 0) {
+ if (oldSize.width > 0 && oldSize.height > 0) {
+
+ Rectangle2D viewRect = viewport.getPreciseViewRect();
+ double widthRatio = (double) newSize.width / oldSize.width;
+ double heightRatio = (double) newSize.height / oldSize.height;
+
+ viewport.setViewPosition(new Point2D.Double(
+ viewRect.getCenterX() - (viewRect.getCenterX() - viewRect.getX()) * widthRatio,
+ viewRect.getCenterY() - (viewRect.getCenterY() - viewRect.getY()) * heightRatio
+ ));
+ }
+
+ oldSize = newSize;
+ }
+ }
+ }
+
+ @Override public void componentMoved(ComponentEvent e) {}
+ @Override public void componentShown(ComponentEvent e) {}
+ @Override public void componentHidden(ComponentEvent e) {}
+ }
+
+ /** The layout manager for this UI. */
+ protected static class ZoomPaneLayout extends FlatScrollPaneLayout implements ZoomPaneConstants {
+
+ /**
+ * The zoompane's zoombar child. Default is a {@code ZoomBar}.
+ * @see ZoomPane#setZoomBar(ZoomBar)
+ */
+ protected ZoomBar zb;
+
+ @Override
+ public void syncWithScrollPane(@NotNull JScrollPane sp) {
+ super.syncWithScrollPane(sp);
+ zb = ((ZoomPane) sp).getZoomBar();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Lastly, the preferred size of the zoombar is added given the current zoombar displayPolicy.
+ *
+ * @param parent {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Dimension preferredLayoutSize(@NotNull Container parent) {
+ Dimension superSize = super.preferredLayoutSize(parent);
+ int zbPolicy = ((ZoomPane) parent).getZoomBarPolicy();
+
+ if (zb != null && zbPolicy == ZOOMBAR_ALWAYS) { superSize.height += zb.getPreferredSize().height; }
+ return superSize;
+ }
+
+ /**
+ * {@inheritDoc} Lastly, the minimum size of the zoombar is added given its displayPolicy isn't NEVER.
+ *
+ * @param parent {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Dimension minimumLayoutSize(@NotNull Container parent) {
+ Dimension superSize = super.minimumLayoutSize(parent);
+ int zbPolicy = ((ZoomPane) parent).getZoomBarPolicy();
+
+ if (zb != null && zbPolicy != ZOOMBAR_NEVER) {
+ Dimension size = zb.getMinimumSize();
+ superSize.height += size.height;
+ superSize.width = Math.max(superSize.width, size.width);
+ }
+ return superSize;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * Note: Since the size of and detail required for drawing the {@link ZoomView} is constantly changing,
+ * the entire view is repainted in its entirety every time the size or position of the view within the
+ * viewport changes. Performance conscious applications should keep these updates to a minimum.
+ *
+ * It is recommended to use {@link #setViewPosition(Point2D)} instead for more precision.
+ *
+ * @param newViewPosition {@inheritDoc}
+ */
+ @Override
+ public void setViewPosition(@NotNull Point newViewPosition) { setViewPosition((Point2D) newViewPosition); }
+
+ /**
+ * Sets the view coordinates that appear in the upper left hand corner of the viewport, does nothing
+ * if there's no view.
+ *
+ * @param newViewPosition a {@code Point2D} object giving the upper left coordinates
+ */
+ public void setViewPosition(@NotNull Point2D newViewPosition) {
+ if (view == null || newViewPosition.equals(viewPosition)) { return; }
+ viewPosition.setLocation(newViewPosition);
+ fireStateChanged();
+ repaint();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @return {@inheritDoc}
+ * @see #getPreciseViewRect()
+ */
+ @Override
+ public @NotNull Rectangle getViewRect() { return super.getViewRect(); }
+
+ /**
+ * Returns a rectangle whose origin is {@link #getPreciseViewPosition} and size is
+ * {@link #getPreciseExtentSize}. This is the visible part of the view in view coordinates.
+ *
+ * @return a {@code Rectangle2D} giving the visible part of the view using view coordinates
+ */
+ public Rectangle2D getPreciseViewRect() {
+ Point2D pos = getPreciseViewPosition();
+ Dimension2D size = getPreciseExtentSize();
+ return new Rectangle2D.Double(pos.getX(), pos.getY(), size.getWidth(), size.getHeight());
+ }
+
+ /**
+ * Sets the view area of the viewport to the given {@code Rectangle2D} after first adjusting it
+ * to match the viewport's aspect ratio.
+ *
+ * @param viewRect the view area to set.
+ */
+ public void setViewRect(@NotNull Rectangle2D viewRect) {
+ if (view == null) { return; }
+
+ double widthRatio = getWidth() / viewRect.getWidth();
+ double heightRatio = getHeight() / viewRect.getHeight();
+ double minRatio = Math.min(widthRatio, heightRatio);
+ widthRatio /= minRatio;
+ heightRatio /= minRatio;
+
+ double oldScaleFactor = scaleFactor;
+ Point2D oldViewPosition = viewPosition;
+ scaleFactor *= minRatio;
+ viewPosition = new Point2D.Double(
+ viewRect.getCenterX() - viewRect.getWidth() * widthRatio / 2,
+ viewRect.getCenterY() - viewRect.getHeight() * heightRatio / 2
+ );
+
+ if (scaleFactor != oldScaleFactor || !viewPosition.equals(oldViewPosition)) {
+ fireStateChanged();
+ repaint();
+ }
+ }
+
+ /**
+ * {@inheritDoc} Since the view inhabits space in double-precision, this method returns the size of the
+ * bounding box on the integer grid.
+ *
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Dimension getExtentSize() {
+ Point2D precisePos = getPreciseViewPosition();
+ Dimension2D preciseSize = getPreciseExtentSize();
+ return new Dimension(
+ (int) (Math.ceil(precisePos.getX() + preciseSize.getWidth()) - Math.floor(precisePos.getX())),
+ (int) (Math.ceil(precisePos.getY() + preciseSize.getHeight() - Math.floor(precisePos.getY())))
+ );
+ }
+
+ /**
+ * Returns the size of the visible part of the view in view coordinates.
+ *
+ * @return a {@code Dimension2D} object giving the viewport's coverage of the view
+ */
+ public Dimension2D getPreciseExtentSize() {
+ if (view == null) { return new Dimension2DDouble(); }
+ return toViewCoordinates((Dimension2D) getSize());
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * It is recommended to use {@link #toViewCoordinates(Dimension2D)} instead for more precision.
+ *
+ * @param size {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Dimension toViewCoordinates(@NotNull Dimension size) {
+ size.setSize(toViewCoordinates((Dimension2D) size));
+ return size;
+ }
+
+ /**
+ * Converts a size in pixel coordinates to view coordinates.
+ *
+ * @param size a {@code Dimension2D} object using pixel coordinates
+ * @return a {@code Dimension2D} object converted to view coordinates
+ */
+ public Dimension2D toViewCoordinates(@NotNull Dimension2D size) {
+ return new Dimension2DDouble(size.getWidth() / scaleFactor, size.getHeight() / scaleFactor);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * It is recommended to use {@link #toViewCoordinates(Point2D)} instead for more precision.
+ *
+ * @param p {@inheritDoc}
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Point toViewCoordinates(@NotNull Point p) {
+ p.setLocation(toViewCoordinates((Point2D) p));
+ return p;
+ }
+
+ /**
+ * Converts a point in pixel coordinates to view coordinates.
+ *
+ * @param p a {@code Point2D} object using pixel coordinates
+ * @return a {@code Point2D} object converted to view coordinates
+ */
+ public Point2D toViewCoordinates(@NotNull Point2D p) {
+ return new Point2D.Double(
+ viewPosition.getX() + p.getX() / scaleFactor,
+ viewPosition.getY() + p.getY() / scaleFactor
+ );
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * It is recommended to use {@link #setExtentSize(Dimension2D)} instead for more precision.
+ *
+ * @param newExtent {@inheritDoc}
+ */
+ @Override
+ public void setExtentSize(@NotNull Dimension newExtent) { setExtentSize((Dimension2D) newExtent); }
+
+ /**
+ * Sets the size of the visible part of the view using view coordinates.
+ *
+ * @param newExtent a {@code Dimension2D} object specifying the size of the view
+ */
+ public void setExtentSize(@NotNull Dimension2D newExtent) {
+ if (view == null) { return; }
+ double widthRatio = getWidth() / newExtent.getWidth();
+ double heightRatio = getHeight() / newExtent.getHeight();
+ setScaleFactor(scaleFactor * Math.min(widthRatio, heightRatio));
+ }
+
+ /** A listener for the view. */
+ protected class ZoomViewListener extends ViewListener implements ChangeListener {
+
+ /** Constructs a {@code ZoomViewListener}. */
+ protected ZoomViewListener() {}
+
+ public void stateChanged(@NotNull ChangeEvent e) {
+ fireStateChanged();
+ repaint();
+ }
+ }
+
+ /**
+ * Creates a listener for the view.
+ *
+ * @return a {@code ViewListener}.
+ */
+ @Override
+ protected ViewListener createViewListener() { return new ZoomViewListener(); }
+
+ @Override
+ protected LayoutManager createLayoutManager() {
+ return new LayoutManager() {
+
+ public void addLayoutComponent(String name, Component comp) {}
+ public void removeLayoutComponent(Component comp) {}
+
+ public Dimension preferredLayoutSize(Container parent) { return parent.getSize(); }
+ public Dimension minimumLayoutSize(Container parent) { return parent.getSize(); }
+
+ public void layoutContainer(Container parent) {
+ ZoomViewport viewport = (ZoomViewport) parent;
+ viewport.getView().setBounds(0, 0, viewport.getWidth(), viewport.getHeight());
+ }
+ };
+ }
+
+ /**
+ * Subclassers can override this to install a different peer in the constructor. Returns the
+ * {@code ZoomPeer} to use as the view of the {@code ZoomViewport}.
+ *
+ * @return a {@code ZoomPeer}
+ */
+ protected ZoomPeer createPeer() { return new ZoomPeer(); }
+
+ /**
+ * Sets the {@code ZoomViewport}'s component peer, which can be {@code null}.
+ *
+ * @param newPeer the viewport's new component peer
+ */
+ public void setPeer(ZoomPeer newPeer) { setView(newPeer); }
+
+ /**
+ * Returns the {@code ZoomViewport}'s component peer or {@code null}.
+ *
+ * @return this viewport's peer, or {@code null} if none exists
+ */
+ public ZoomPeer getPeer() { return (ZoomPeer) getView(); }
+
+ /**
+ * The component child of {@code ZoomViewport} that acts as a peer to the {@code ZoomView}. It provides
+ * the view with an unscaled surface on which to add popups and menus in addition to having installed the
+ * view's controllers.
+ */
+ public class ZoomPeer extends JPanel {
+
+ /** Identifies a component that should be laid out as a notification. */
+ public static final String NOTIFICATION = "NOTIFICATION";
+
+ /**
+ * Mapping of controllers to the {@code MouseInputListeners} installed on this component that create
+ * {@code ControllerMouseEvent}s for the controllers.
+ */
+ private final HashMap
+ * Unlike other classes in {@link java.awt.geom}, the {@code Dimension2D} class does not provide inner
+ * {@code Float} and {@code Double} classes. This class is a necessary {@code Dimension2D.Double}
+ * implementation.
+ */
+ public static class Dimension2DDouble extends Dimension2D implements Serializable {
+
+ /** The width of this {@code Dimension2D}.*/
+ public double width;
+
+ /** The height of this {@code Dimension2D}.*/
+ public double height;
+
+ /** Constructs and initializes a {@code Dimension2D} with lengths (0, 0). */
+ public Dimension2DDouble() { setSize(0, 0); }
+
+ /**
+ * Constructs and initializes a {@code Dimension2D} with the specified lengths.
+ *
+ * @param width the width of the newly constructed {@code Dimension2D}.
+ * @param height the height of the newly constructed {@code Dimension2D}.
+ */
+ public Dimension2DDouble(double width, double height) { setSize(width, height); }
+
+
+ @Override
+ public double getWidth() { return width; }
+
+ @Override
+ public double getHeight() { return height; }
+
+ @Override
+ public void setSize(double width, double height) {
+ this.width = width;
+ this.height = height;
+ }
+
+ /**
+ * Returns a {@code String} that represents the value of this {@code Dimension2D}.
+ *
+ * @return a string representation of this {@code Dimension2D}.
+ */
+ public String toString() { return "Dimension2DDouble[" + width + ", " + height + "]"; }
+
+ /** Use serialVersionUID from JDK 1.6 for interoperability. */
+ @Serial
+ private static final long serialVersionUID = 6150783262733311327L;
+ }
+
+ /**
+ * Returns a string representation of this {@code ZoomViewport}. This method is intended to be used only
+ * for debugging purposes, and the content and format of the returned string may vary between implementations.
+ * The returned string may be empty but may not be {@code null}.
+ *
+ * @return a string representation of this {@code ZoomViewport}
+ */
+ protected String paramString() {
+ String paramString = super.paramString();
+ paramString = paramString.substring(0, paramString.indexOf(",isViewSizeSet="));
+ return paramString +
+ ",scaleFactor=" + scaleFactor +
+ ",viewPosition=" + viewPosition +
+ ",extentSize=" + getPreciseExtentSize() +
+ ",view=" + view;
+ }
+}
diff --git a/src/main/resources/edu/rpi/legup/themes/FlatLaf.properties b/src/main/resources/edu/rpi/legup/themes/FlatLaf.properties
index 949cdce67..c45c9ab16 100644
--- a/src/main/resources/edu/rpi/legup/themes/FlatLaf.properties
+++ b/src/main/resources/edu/rpi/legup/themes/FlatLaf.properties
@@ -38,8 +38,18 @@ Tree.contradiction = #B20A10
Tree.selected = #1E88E5
Tree.selectedOutline = lighten($Tree.selected, 10%)
Tree.selectedWidth = 2
-Tree.hover = $Puzzle.hover
+Tree.hover = @hover
Tree.hoverOutline = lighten($Tree.hover, 10%)
+Tree.viewPadding = {Insets} 50,150,50,150
+Tree.nodeRadius = 25
+Tree.contradictionWidth = 5
+Tree.transitionTailGap = 60
+Tree.transitionArc = 12
+Tree.transitionHeadBase = 24
+Tree.transitionHeadHeight = 20
+Tree.transitionTailWeight = 6
+Tree.verticalGap = 20
+Tree.horizontalGap = 10
# Styles for individual puzzle types
@@ -201,14 +211,37 @@ Expected.SvgIcon.text = #222222
Expected.SvgIcon.borderColor = #232323
+# Styles for custom components
+
+# ZoomPane
+ZoomPaneUI = edu.rpi.legup.ui.zoompane.ZoomPaneUI
+ZoomPane.smoothScrolling = true
+ZoomPane.viewPadding = {insets} 10,10,10,10
+
+# ZoomBar
+ZoomBarUI = edu.rpi.legup.ui.zoompane.ZoomBarUI
+ZoomBar.width = 16
+ZoomBar.minimumThumbSize = 12,12
+ZoomBar.trackInsets = 6,6,6,6
+ZoomBar.trackArc = 999
+ZoomBar.showButtons = true
+ZoomBar.showFitButton = true
+ZoomBar.fitButtonGap = 2
+ZoomBar.background = $ScrollBar.track
+ZoomBar.thumb = if(@accentColor, @accentColor, @accentBase2Color)
+ZoomBar.hoverThumbColor = darken($ZoomBar.thumb,5%,derived)
+ZoomBar.pressedThumbColor = darken($ZoomBar.thumb,8%,derived)
+ZoomBar.track = $Slider.disabledTrackColor
+ZoomBar.hoverTrackColor = $Slider.trackColor
+
+
# Styles for various UI elements
[style]Label.welcome = font: 17 bold; border: 0,0,0,5;
[style]Label.version = font: 10 italic; border: 0,0,0,5;
-[style]Label.info = font: {font} 14; foreground: @foreground; background: @background; border: 5,15,5,15,mix(@foreground,@background,25%),3,10;
-[style]Label.error = font: 14 italic; foreground: @invalid; background: @background; border: 5,15,5,15,mix(@invalid,@background,25%),3,10;
+[style]Label.info = font: {font} 14; foreground: @foreground; background: changeAlpha(@background,90%); border: 5,15,5,15,changeAlpha(@foreground,40%),3,10;
+[style]Label.error = font: 14 italic; foreground: @invalid; background: changeAlpha(@background,90%); border: 5,15,5,15,changeAlpha(@invalid,40%),3,10;
[style]Label.prefsHeading = font: 14 bold;
-[style]Label.zoomer = font: {font} 16;
[style]Button.rule = font: 12 "Segoe UI";
[style]Button.smallRule = font: 10 "Segoe UI";
> newOrder = new ArrayList<>();
- for (Set
+ *
+ *
+ *
+ * ]
+ *
+ *
+ * @param policy one of the two values listed above
+ * @throws IllegalArgumentException if {@code policy} is not one of the legal values shown above
+ * @see #getZoomBarPolicy
+ */
+ @BeanProperty(preferred = true,
+ enumerationValues = {"ZoomPaneConstants.ZOOMBAR_NEVER", "ZoomPaneConstants.ZOOMBAR_ALWAYS"},
+ description = "The ZoomPane ZoomBar policy")
+ public void setZoomBarPolicy(int policy) {
+ if (policy != ZOOMBAR_NEVER && policy != ZOOMBAR_ALWAYS) {
+ throw new IllegalArgumentException("invalid zoomBarPolicy");
+ }
+ int old = zoomBarPolicy;
+ zoomBarPolicy = policy;
+ firePropertyChange("zoomBarPolicy", old, policy);
+ revalidate();
+ repaint();
+ }
+
+ private static class ZoomBarUIResource extends ZoomBar implements UIResource {}
+
+ /**
+ * Returns a new {@code ZoomBar} by default. Subclasses may override this method to force
+ * {@code ZoomPaneUI} implementations to use a {@code ZoomBar} subclass. Used by
+ * {@code ZoomPaneUI} implementations to create the zoom bar.
+ *
+ * @return a {@code ZoomBar}
+ * @see ZoomBar
+ */
+ public ZoomBar createZoomBar() { return new ZoomBarUIResource(); }
+
+ /**
+ * Returns the zoom bar that controls the viewport's zoom level.
+ *
+ * @return the {@code zoomBar} property
+ * @see #setZoomBar(ZoomBar)
+ */
+ @Transient
+ public ZoomBar getZoomBar() { return zoomBar; }
+
+ /**
+ * Adds the zoom bar that controls the viewport's zoom to the zoom pane. This is usually unnecessary,
+ * as {@code ZoomPane} creates a zoom bar by default.
+ *
+ * @param zoomBar the zoom bar to be added
+ * @see #createZoomBar()
+ * @see #getZoomBar()
+ */
+ @BeanProperty(expert = true, description = "The zoom bar.")
+ public void setZoomBar(@Nullable ZoomBar zoomBar) {
+ ZoomBar old = getZoomBar();
+ this.zoomBar = zoomBar;
+ if (zoomBar != null) { try { add(zoomBar, ZOOMBAR); } catch (IllegalArgumentException ignored) {;} }
+ else if (old != null) { remove(old); }
+ firePropertyChange("zoomBar", old, zoomBar);
+
+ revalidate();
+ repaint();
+ }
+
+ /**
+ * Returns a new {@code ZoomViewport} by default. Used to create the viewport (as needed)
+ * in {@link #setViewportView(ZoomView)}. Subclasses may override this method to return a subclass of
+ * {@code JViewport}.
+ *
+ * @return a new {@code ZoomViewport}
+ */
+ @Override
+ protected JViewport createViewport() { return new ZoomViewport(); }
+
+ /**
+ * Removes the old viewport (if there is one); syncs the scroll bars and zoom bar with the new viewport.
+ *
+ * ZoomPane zoompane = new ZoomPane();
+ * zoompane.setViewportView(zoomViewObject);
+ *
+ * Applications should not add children directly to the {@code ZoomPane}.
+ *
+ * @param view the view to add to the viewport
+ * @see #setViewport(ZoomViewport)
+ * @see ZoomViewport#setZoomView(ZoomView)
+ */
+ public void setViewportView(@Nullable ZoomView view) {
+ if (getViewport() == null) { setViewport((ZoomViewport) createViewport()); }
+ ((ZoomViewport) getViewport()).setZoomView(view);
+ }
+
+ /**
+ * Creates a viewport if necessary and then sets its view. Applications that don't provide the view
+ * directly to the {@code ZoomPane} constructor should use this method to specify the {@code ZoomView}
+ * child that's going to be displayed in the {@code ZoomPane}. For example:
+ *
+ * ZoomPane zoompane = new ZoomPane();
+ * zoompane.setViewportView(zoomViewObject);
+ *
+ * Applications should not add children directly to the {@code ZoomPane}.
+ * @deprecated use {@link #setViewportView(ZoomView)} instead; {@code ZoomViewport} accepts a
+ * {@code ZoomView} as its view rather than a {@code Component}
+ *
+ * @param view {@inheritDoc}
+ * @see #setViewport(ZoomViewport)
+ * @see ZoomViewport#setZoomView(ZoomView)
+ */
+ @Deprecated
+ @Override
+ public void setViewportView(@Nullable Component view) {
+ if (view == null) { setViewportView((ZoomView) null); }
+ else { throw new IllegalArgumentException("ZoomPane's view cannot be set to a Component"); }
+ }
+
+ /**
+ * Removes the old rowHeader (if it exists).
+ * @deprecated implementing a rowHeader for {@code ZoomPane} is difficult and so far unnecessary, so
+ * attempting to set the rowHeader to a non-{@code null} value will throw an exception.
+ *
+ * @param rowHeader {@inheritDoc}
+ * @throws IllegalArgumentException if rowHeader is non-{@code null}
+ * @see #getRowHeader()
+ * @see #setRowHeaderView(Component)
+ */
+ @Deprecated
+ @Override
+ public void setRowHeader(JViewport rowHeader) {
+ if (rowHeader == null) { super.setRowHeader(null); }
+ else { throw new IllegalArgumentException("rowHeader for ZoomPane has not yet been implemented."); }
+ }
+
+ /**
+ * Removes the old columnHeader (if it exists).
+ * @deprecated implementing a columnHeader for {@code ZoomPane} is difficult and so far unnecessary,
+ * so attempting to set the columnHeader to a non-{@code null} value will throw an exception.
+ *
+ * @param columnHeader {@inheritDoc}
+ * @throws IllegalArgumentException if columnHeader is non-{@code null}
+ * @see #getColumnHeader()
+ * @see #setColumnHeaderView(Component)
+ */
+ @Deprecated
+ @Override
+ public void setColumnHeader(JViewport columnHeader) {
+ if (columnHeader == null) { super.setColumnHeader(null); }
+ else { throw new IllegalArgumentException("columnHeader for ZoomPane has not yet been implemented."); }
+ }
+
+ /**
+ * Sets the orientation for the vertical and horizontal scroll bars and the zoom bar as determined
+ * by the {@code ComponentOrientation} argument.
+ *
+ * @param co {@inheritDoc}
+ * @see java.awt.ComponentOrientation
+ */
+ @Override
+ public void setComponentOrientation(ComponentOrientation co) {
+ super.setComponentOrientation(co);
+ if (zoomBar != null) { zoomBar.setComponentOrientation(co); }
+ }
+
+ /**
+ * Returns the sensitivity of zooming on this {@code ZoomPane}.
+ *
+ * @return zooming sensitivity
+ * @see #setSensitivity(double)
+ */
+ @BeanProperty(description = "Sensitivity of zooming")
+ public double getSensitivity() { return sensitivity; }
+
+ /**
+ * Sets the sensitivity of zooming on this {@code ZoomPane}. To gauge a good value use the formula
+ * {@code sensitivity = 1 / steps} where steps is the number of scroll wheel ticks to cover the zoom range.
+ *
+ * @param newSensitivity the new sensitivity value to set
+ * @see #getSensitivity()
+ */
+ @BeanProperty(description = "Sensitivity of zooming")
+ public void setSensitivity(double newSensitivity) {
+ double old = sensitivity;
+ sensitivity = newSensitivity;
+ firePropertyChange("sensitivity", old, sensitivity);
+ }
+
+ /**
+ * Returns the minimum scale factor to be applied to the viewport's view. If {@code minScaleRelative}
+ * is {@code true}, this value should instead be used as the ratio of how many of the view's size
+ * should be able to fit into the viewport on its relatively longest size.
+ *
+ * @return the minimum scale factor
+ * @see #setMinScaleFactor(double)
+ * @see #isMinScaleRelative()
+ * @see #getMaxScaleFactor()
+ */
+ @BeanProperty(description = "Minimum scale factor")
+ public double getMinScaleFactor() { return minScaleFactor; }
+
+ /**
+ * Sets the minimum scale factor to be applied to the viewport's view. If {@code minScaleRelative}
+ * is {@code true}, this value will instead be used as the ratio of how many of the view's size
+ * should be able to fit into the viewport on its relatively longest size.
+ *
+ * @param newMinScaleFactor the new minimum scale factor to set
+ * @see #getMinScaleFactor()
+ * @see #setMinScaleRelative(boolean)
+ * @see #setMaxScaleFactor(double)
+ */
+ @BeanProperty(description = "Minimum scale factor")
+ public void setMinScaleFactor(double newMinScaleFactor) {
+ double old = minScaleFactor;
+ minScaleFactor = newMinScaleFactor;
+ firePropertyChange("minScaleFactor", old, minScaleFactor);
+ }
+
+ /**
+ * Returns whether {@code minScaleFactor} is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively longest side.
+ *
+ * @return {@code true} if the {@code minScaleFactor} is relative, {@code false} otherwise
+ * @see #getMinScaleFactor()
+ * @see #setMinScaleRelative(boolean)
+ */
+ @BeanProperty(description = "Flag for whether the minimum scale factor is relative to its fit")
+ public boolean isMinScaleRelative() { return minScaleRelative; }
+
+ /**
+ * Sets whether {@code minScaleFactor} is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively longest side.
+ *
+ * @param newMinScaleRelative {@code true} if the {@code minScaleFactor} should be relative,
+ * {@code false} otherwise
+ * @see #setMinScaleFactor(double)
+ * @see #isMinScaleRelative()
+ */
+ @BeanProperty(description = "Flag for whether the minimum scale factor is relative to its fit")
+ public void setMinScaleRelative(boolean newMinScaleRelative) {
+ boolean old = minScaleRelative;
+ minScaleRelative = newMinScaleRelative;
+ firePropertyChange("minScaleRelative", old, minScaleRelative);
+ }
+
+ /**
+ * Returns the maximum scale factor to be applied to the viewport's view. If {@code maxScaleRelative}
+ * is {@code true}, this value should instead be used as the ratio of how many of the view's size
+ * should be able to fit into the viewport on its relatively shortest size.
+ *
+ * @return the maximum scale factor
+ * @see #setMaxScaleFactor(double)
+ * @see #isMaxScaleRelative()
+ * @see #getMinScaleFactor()
+ */
+ @BeanProperty(description = "Maximum scale factor")
+ public double getMaxScaleFactor() { return maxScaleFactor; }
+
+ /**
+ * Sets the maximum scale factor to be applied to the viewport's view. If {@code maxScaleRelative}
+ * is {@code true}, this value will instead be used as the ratio of how many of the view's size
+ * should be able to fit into the viewport on its relatively shortest size.
+ *
+ * @param newMaxScaleFactor the new maximum scale factor to set
+ * @see #getMaxScaleFactor()
+ * @see #setMaxScaleRelative(boolean)
+ * @see #setMinScaleFactor(double)
+ */
+ @BeanProperty(description = "Maximum scale factor")
+ public void setMaxScaleFactor(double newMaxScaleFactor) {
+ double old = maxScaleFactor;
+ maxScaleFactor = newMaxScaleFactor;
+ firePropertyChange("maxScaleFactor", old, maxScaleFactor);
+ }
+
+ /**
+ * Returns whether {@code maxScaleFactor} is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively shortest side.
+ *
+ * @return {@code true} if the {@code maxScaleFactor} is relative, {@code false} otherwise
+ * @see #getMaxScaleFactor()
+ * @see #setMaxScaleRelative(boolean)
+ */
+ @BeanProperty(description = "Flag for whether the maximum scale factor is relative to its fit")
+ public boolean isMaxScaleRelative() { return maxScaleRelative; }
+
+ /**
+ * Sets whether {@code maxScaleFactor} is a direct scale factor value or a ratio for how many
+ * multiples of the view's size should be able to fit into the viewport on its relatively shortest side.
+ *
+ * @param newMaxScaleRelative {@code true} if the {@code maxScaleFactor} should be relative,
+ * {@code false} otherwise
+ * @see #setMaxScaleFactor(double)
+ * @see #isMaxScaleRelative()
+ */
+ @BeanProperty(description = "Flag for whether the maximum scale factor is relative to its fit")
+ public void setMaxScaleRelative(boolean newMaxScaleRelative) {
+ boolean old = maxScaleRelative;
+ maxScaleRelative = newMaxScaleRelative;
+ firePropertyChange("maxScaleRelative", old, maxScaleRelative);
+ }
+
+ /**
+ * Returns a string representation of this {@code ZoomPane}. This method is intended to be used
+ * only for debugging purposes, and the content and format of the returned string may vary between
+ * implementations. The returned string may be empty but may not be {@code null}.
+ *
+ * @return a string representation of this {@code ZoomPane}
+ */
+ @Override
+ public String paramString() {
+ return super.paramString() +
+ ",zoomBar=" + (zoomBar != null ? zoomBar : "") +
+ ",sensitivity=" + sensitivity +
+ ",minScaleFactor=" + minScaleFactor +
+ ",minScaleRelative=" + minScaleRelative +
+ ",maxScaleFactor=" + maxScaleFactor +
+ ",maxScaleRelative=" + maxScaleRelative;
+ }
+
+/////////////////
+// Accessibility support
+////////////////
+
+ /**
+ * Gets the {@code AccessibleContext} associated with this {@code ZoomPane}. For zoom panes, this
+ * takes the form of an {@code AccessibleZoomPane}, of which a new instance is created if necessary.
+ *
+ * @return an {@code AccessibleZoomPane} that serves as the {@code AccessibleContext} of this
+ * {@code ZoomPane}
+ */
+ @Override
+ public AccessibleContext getAccessibleContext() {
+ if (accessibleContext == null) { accessibleContext = new AccessibleZoomPane(); }
+ return accessibleContext;
+ }
+
+ /**
+ * This class implements accessibility support for the ZoomPane class. It provides
+ * an implementation of the Java Accessibility API appropriate to scroll pane user-interface elements.
+ */
+ protected class AccessibleZoomPane extends AccessibleJScrollPane {
+
+ /**
+ * AccessibleZoomPane constructor.
+ */
+ public AccessibleZoomPane() {
+ super();
+ ZoomBar zoomBar = getZoomBar();
+ if (zoomBar != null) { setZoomBarRelations(zoomBar); }
+ }
+
+ @Override
+ public void propertyChange(PropertyChangeEvent e) {
+ super.propertyChange(e);
+ if (e.getPropertyName().equals("zoomBar") && e.getNewValue() instanceof ZoomBar) {
+ setZoomBarRelations((ZoomBar) e.getNewValue());
+ }
+ }
+
+ /**
+ * Sets the {@code CONTROLLER_FOR} and {@code CONTROLLED_BY} AccessibleRelations for the
+ * {@code ZoomPane} and {@code ZoomBar}.
+ *
+ * @param zoomBar the {@code ZoomBar} to set relations with. Must not be {@code null}
+ */
+ void setZoomBarRelations(ZoomBar zoomBar) {
+ // The ZoomBar is a CONTROLLER_FOR the ZoomPane. The ZoomPane is CONTROLLED_BY the ZoomBar.
+ AccessibleRelation controlledBy =
+ new AccessibleRelation(AccessibleRelation.CONTROLLED_BY, zoomBar);
+ AccessibleRelation controllerFor =
+ new AccessibleRelation(AccessibleRelation.CONTROLLER_FOR, ZoomPane.this);
+
+ AccessibleContext ac = zoomBar.getAccessibleContext();
+ ac.getAccessibleRelationSet().add(controllerFor);
+ getAccessibleRelationSet().add(controlledBy);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneConstants.java b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneConstants.java
new file mode 100644
index 000000000..377842342
--- /dev/null
+++ b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneConstants.java
@@ -0,0 +1,19 @@
+package edu.rpi.legup.ui.zoompane;
+
+import javax.swing.ScrollPaneConstants;
+
+/** Constants used with the ZoomPane component. */
+public interface ZoomPaneConstants extends ScrollPaneConstants {
+
+ /** Identifies a horizontal scrollbar. */
+ String ZOOMBAR = "ZOOMBAR";
+
+ /** Identifies the zoom bar policy property. */
+ String ZOOMBAR_POLICY = "ZOOMBAR_POLICY";
+
+ /** Used to set the zoom bar policy so that zoom bars are never displayed. */
+ int ZOOMBAR_NEVER = 41;
+
+ /** Used to set the zoom bar policy so that zoom bars are always displayed. */
+ int ZOOMBAR_ALWAYS = 42;
+}
diff --git a/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneUI.java b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneUI.java
new file mode 100644
index 000000000..e7a054530
--- /dev/null
+++ b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomPaneUI.java
@@ -0,0 +1,1069 @@
+package edu.rpi.legup.ui.zoompane;
+
+import com.formdev.flatlaf.ui.FlatScrollPaneUI;
+import com.formdev.flatlaf.ui.FlatStylingSupport;
+import com.formdev.flatlaf.util.LoggingFacade;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.awt.*;
+import java.awt.event.*;
+import java.awt.geom.Dimension2D;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+import java.util.Objects;
+import javax.swing.*;
+import javax.swing.border.Border;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import javax.swing.plaf.ActionMapUIResource;
+import javax.swing.plaf.ComponentUI;
+import javax.swing.plaf.UIResource;
+import javax.swing.plaf.basic.BasicScrollPaneUI;
+
+/** Provides the Flat Laf UI delegate for {@link ZoomPane}. */
+public class ZoomPaneUI extends FlatScrollPaneUI {
+
+ /** Re-usable handler instance for event handling. */
+ private Handler handler;
+
+ /** {@code MouseWheelListener} installed on the viewport. */
+ private MouseWheelListener mouseZoomListener;
+
+ /** {@code ActionListener} installed on the zoom bar for fitting the view to the viewport. */
+ protected ActionListener zbFitListener;
+
+ /** {@code ChangeListener} installed on the zoom bar. */
+ protected ChangeListener zbChangeListener;
+
+ /** {@code PropertyChangeListener} installed on the zoom bar. */
+ private PropertyChangeListener zbPropertyChangeListener;
+
+ /** {@code PropertyChangeListener} installed on the viewport. */
+ private PropertyChangeListener viewportPropertyChangeListener;
+
+ /** {@code ComponentListener} installed on the viewport. */
+ private ComponentListener viewportResizeListener;
+
+ /**
+ * Flag indicating that the viewport should fit its view as soon as it is possible.
+ * @see Actions#readyToFit(ZoomPane)
+ */
+ protected boolean waitingToFit;
+
+ /**
+ * Flag indicating that the viewport has fit its view and should re-fit its view every time the view changes
+ * until an action is taken.
+ *
+ * @see Actions#readyToFit(ZoomPane)
+ */
+ protected boolean isFit;
+
+ /**
+ * Flag indicating that state change events should be ignored (usually because they were triggered by
+ * a call from this class).
+ * @see #syncScrollPaneWithViewport()
+ * @see Handler#actionPerformed(ActionEvent)
+ */
+ protected boolean ignoreStateChanges;
+
+ public static ComponentUI createUI(@NotNull JComponent c) { return new ZoomPaneUI(); }
+
+ /** @return the {@code viewPadding} client property or UI Default associated with the given {@code ZoomPane} */
+ protected static Insets getViewPadding(ZoomPane zoomPane) {
+ Object viewPadding = zoomPane.getClientProperty("ZoomPane.viewPadding");
+ if (viewPadding instanceof Insets) { return (Insets) viewPadding; }
+ viewPadding = UIManager.getInsets("ZoomPane.viewPadding");
+ if (viewPadding != null) { return (Insets) viewPadding; }
+ return new Insets(0, 0, 0, 0);
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param c {@inheritDoc}
+ * @throws IllegalArgumentException if c is not a {@code ZoomPane}
+ */
+ @Override
+ public void installUI(@NotNull JComponent c) {
+ if (c instanceof ZoomPane) { super.installUI(c); }
+ else { throw new IllegalArgumentException("ZoomPaneUI can only be applied to a ZoomPane."); }
+
+ int focusWidth = UIManager.getInt("Component.focusWidth");
+ int arc = getArc();
+ LookAndFeel.installProperty(c, "opaque", focusWidth == 0 && arc == 0);
+
+ if (Actions.readyToFit((ZoomPane) scrollpane)) {
+ Actions.zoomToFit((ZoomPane) scrollpane);
+ waitingToFit = false;
+ }
+ else { waitingToFit = true; }
+ }
+
+ @Override
+ protected void installDefaults(@NotNull JScrollPane c) {
+ super.installDefaults(scrollpane);
+
+ UIDefaults defaults = UIManager.getDefaults();
+ if (defaults.containsKey("ZoomPane.border")) {
+ LookAndFeel.installBorder(scrollpane, "ZoomPane.border");
+ }
+ if (defaults.containsKey("ZoomPane.background")) {
+ LookAndFeel.installColorsAndFont(scrollpane, "ZoomPane.background",
+ "ScrollPane.foreground", "ScrollPane.font");
+ }
+
+ Border vpBorder = scrollpane.getViewportBorder();
+ if (defaults.containsKey("ZoomPane.viewportBorder") &&
+ ((vpBorder == null) ||( vpBorder instanceof UIResource))) {
+ scrollpane.setViewportBorder(UIManager.getBorder("ZoomPane.viewportBorder"));
+ }
+ }
+
+ @Override
+ protected void installListeners(@NotNull JScrollPane c) {
+ super.installListeners(c);
+
+ mouseZoomListener = createViewportMouseWheelListener();
+ zbChangeListener = createZBChangeListener();
+ zbFitListener = createZBFitListener();
+ zbPropertyChangeListener = createZBPropertyChangeListener();
+ viewportPropertyChangeListener = createViewportPropertyChangeListener();
+ viewportResizeListener = createViewportResizeListener();
+
+ JViewport viewport = scrollpane.getViewport();
+ ZoomBar zoomBar = ((ZoomPane) scrollpane).getZoomBar();
+
+ if (viewport != null) {
+ viewport.addMouseWheelListener(mouseZoomListener);
+ viewport.addPropertyChangeListener(viewportPropertyChangeListener);
+ viewport.addComponentListener(viewportResizeListener);
+ }
+ if (zoomBar != null) {
+ zoomBar.getModel().addChangeListener(zbChangeListener);
+ JButton fit = zoomBar.getFitButton();
+ if (fit != null) { fit.addActionListener(zbFitListener); }
+ zoomBar.addPropertyChangeListener(zbPropertyChangeListener);
+ }
+ }
+
+ @Override
+ protected void uninstallListeners(@NotNull JComponent c) {
+ super.uninstallListeners(c);
+
+ JViewport viewport = scrollpane.getViewport();
+ ZoomBar zoomBar = ((ZoomPane) scrollpane).getZoomBar();
+
+ if (viewport != null) {
+ viewport.removeMouseWheelListener(mouseZoomListener);
+ viewport.removePropertyChangeListener(viewportPropertyChangeListener);
+ viewport.removeComponentListener(viewportResizeListener);
+ }
+ if (zoomBar != null) {
+ zoomBar.getModel().removeChangeListener(zbChangeListener);
+ JButton fit = zoomBar.getFitButton();
+ if (fit != null) { fit.removeActionListener(zbFitListener); }
+ zoomBar.removePropertyChangeListener(zbPropertyChangeListener);
+ }
+
+ mouseZoomListener = null;
+ zbChangeListener = null;
+ zbFitListener = null;
+ zbPropertyChangeListener = null;
+ viewportPropertyChangeListener = null;
+ viewportResizeListener = null;
+ handler = null;
+ }
+
+ @Override
+ protected void installKeyboardActions(@NotNull JScrollPane c) {
+ SwingUtilities.replaceUIInputMap(c, JComponent.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT, getInputMap());
+ SwingUtilities.replaceUIActionMap(c, getActionMap());
+ }
+
+ /** Attempts to retrieve the {@code InputMap} from the {@code UIManager} and creates one if none is found. */
+ private InputMap getInputMap() {
+ InputMap keyMap = (InputMap) UIManager.get("ZoomPane.ancestorInputMap");
+ if (keyMap != null) { return keyMap; }
+
+ keyMap = LookAndFeel.makeInputMap(new Object[] {
+ "ctrl HOME", Actions.SCROLL_HOME,
+ "UP", Actions.UNIT_SCROLL_UP,
+ "KP_UP", Actions.UNIT_SCROLL_UP,
+ "DOWN", Actions.UNIT_SCROLL_DOWN,
+ "KP_DOWN", Actions.UNIT_SCROLL_DOWN,
+ "LEFT", Actions.UNIT_SCROLL_LEFT,
+ "KP_LEFT", Actions.UNIT_SCROLL_LEFT,
+ "RIGHT", Actions.UNIT_SCROLL_RIGHT,
+ "KP_RIGHT", Actions.UNIT_SCROLL_RIGHT,
+ "PAGE_UP", Actions.SCROLL_UP,
+ "PAGE_DOWN", Actions.SCROLL_DOWN,
+ "ctrl PAGE_UP", Actions.SCROLL_LEFT,
+ "ctrl PAGE_DOWN", Actions.SCROLL_RIGHT,
+ "PLUS", Actions.UNIT_ZOOM_IN,
+ "ADD", Actions.UNIT_ZOOM_IN,
+ "ctrl PLUS", Actions.ZOOM_IN,
+ "ctrl ADD", Actions.ZOOM_IN,
+ "MINUS", Actions.UNIT_ZOOM_OUT,
+ "SUBTRACT", Actions.UNIT_ZOOM_OUT,
+ "ctrl MINUS", Actions.ZOOM_OUT,
+ "ctrl SUBTRACT", Actions.ZOOM_OUT
+ });
+ UIManager.getLookAndFeelDefaults().put("ZoomPane.ancestorInputMap", keyMap);
+ return keyMap;
+ }
+
+ /** Attempts to retrieve the {@code ActionMap} from the {@code UIManager} and creates one if none is found. */
+ private ActionMap getActionMap() {
+ ActionMap actionMap = (ActionMap) UIManager.get("ZoomPane.actionMap");
+ if (actionMap != null) { return actionMap; }
+
+ actionMap = new ActionMapUIResource();
+ actionMap.put(Actions.SCROLL_HOME, new Actions(Actions.SCROLL_HOME));
+ actionMap.put(Actions.UNIT_SCROLL_UP, new Actions(Actions.UNIT_SCROLL_UP));
+ actionMap.put(Actions.UNIT_SCROLL_DOWN, new Actions(Actions.UNIT_SCROLL_DOWN));
+ actionMap.put(Actions.UNIT_SCROLL_LEFT, new Actions(Actions.UNIT_SCROLL_LEFT));
+ actionMap.put(Actions.UNIT_SCROLL_RIGHT, new Actions(Actions.UNIT_SCROLL_RIGHT));
+ actionMap.put(Actions.SCROLL_UP, new Actions(Actions.SCROLL_UP));
+ actionMap.put(Actions.SCROLL_DOWN, new Actions(Actions.SCROLL_DOWN));
+ actionMap.put(Actions.SCROLL_LEFT, new Actions(Actions.SCROLL_LEFT));
+ actionMap.put(Actions.SCROLL_RIGHT, new Actions(Actions.SCROLL_RIGHT));
+ actionMap.put(Actions.UNIT_ZOOM_IN, new Actions(Actions.UNIT_ZOOM_IN));
+ actionMap.put(Actions.UNIT_ZOOM_OUT, new Actions(Actions.UNIT_ZOOM_OUT));
+ actionMap.put(Actions.ZOOM_IN, new Actions(Actions.ZOOM_IN));
+ actionMap.put(Actions.ZOOM_OUT, new Actions(Actions.ZOOM_OUT));
+
+ UIManager.getLookAndFeelDefaults().put("ZoomPane.actionMap", actionMap);
+ return actionMap;
+ }
+
+ @Override
+ protected ZoomPaneLayout createScrollPaneLayout() { return new ZoomPaneLayout(); }
+
+ @Override
+ protected void installStyle() {
+ try {
+ applyStyle(FlatStylingSupport.getResolvedStyle(scrollpane, "ZoomPane"));
+ } catch (RuntimeException e) {
+ LoggingFacade.INSTANCE.logSevere(null, e);
+ }
+ }
+
+ @Override
+ protected Object applyStyleProperty(@NotNull String key, @Nullable Object value) {
+ Object toRet = super.applyStyleProperty(key, value);
+
+ if (key.equals("focusWidth") || key.equals("arc")) {
+ int focusWidth = (value instanceof Integer && key.equals("focusWidth")) ?
+ (int) value : UIManager.getInt("Component.focusWidth");
+ int arc = (value instanceof Integer && key.equals("arc")) ? (int) value : getArc();
+
+ LookAndFeel.installProperty(scrollpane, "opaque",
+ focusWidth == 0 && arc == 0);
+ }
+
+ return toRet;
+ }
+
+ @Override
+ protected void syncScrollPaneWithViewport() {
+ ZoomPane zoomPane = (ZoomPane) scrollpane;
+ ZoomViewport viewport = (ZoomViewport) zoomPane.getViewport();
+ JScrollBar vsb = zoomPane.getVerticalScrollBar();
+ JScrollBar hsb = zoomPane.getHorizontalScrollBar();
+ ZoomBar zb = zoomPane.getZoomBar();
+ ZoomViewport rowHead = (ZoomViewport) zoomPane.getRowHeader();
+ ZoomViewport colHead = (ZoomViewport) zoomPane.getColumnHeader();
+ Insets padding = getViewPadding(zoomPane);
+
+ // If view just changed size from 0, may need to fit it
+ ignoreStateChanges = true;
+ if ((isFit || waitingToFit) && Actions.readyToFit(zoomPane)) {
+ Actions.zoomToFit(zoomPane);
+ waitingToFit = false;
+ }
+ else { viewport.setViewPosition(Actions.clampViewPos(zoomPane, viewport.getPreciseViewPosition())); }
+
+ if (viewport != null) {
+ Dimension2D extentSize = viewport.getPreciseExtentSize();
+ Dimension2D viewSize = viewport.getViewSize();
+ Point2D viewPos = viewport.getPreciseViewPosition();
+ double scaleFactor = viewport.getScaleFactor();
+
+ if (vsb != null) {
+ int extent = (int) Math.ceil(extentSize.getHeight() * scaleFactor);
+ int min = -padding.top;
+ int max = padding.bottom + (int) Math.ceil(viewSize.getHeight() * scaleFactor);
+ int value = (int) Math.max(min, Math.min(max - extent, Math.round(
+ ((viewPos.getY() + padding.top / scaleFactor) / (viewSize.getHeight() - extentSize.getHeight()
+ + (padding.bottom + padding.top) / scaleFactor))
+ * (max - extent - min) + min
+ )));
+ vsb.setValues(value, extent, min, max);
+ }
+ if (hsb != null) {
+ int extent = (int) Math.ceil(extentSize.getWidth() * scaleFactor);
+ int min = -padding.left;
+ int max = padding.right + (int) Math.ceil(viewSize.getWidth() * scaleFactor);
+ int value = (int) Math.max(min, Math.min(max - extent, Math.round(
+ ((viewPos.getX() + padding.left / scaleFactor) / (viewSize.getWidth() - extentSize.getWidth()
+ + (padding.right + padding.left) / scaleFactor))
+ * (max - extent - min) + min
+ )));
+ hsb.setValues(value, extent, min, max);
+ }
+ if (zb != null) {
+ int max = 1000;
+ double zoom = Actions.scaleFactorToZoom(zoomPane, viewport.getScaleFactor());
+ int value = Math.clamp(Math.round(zoom * max), 0, max);
+ zb.setValues(value, 0, 0, max);
+ }
+
+ if (rowHead != null) {
+ Point2D p = rowHead.getPreciseViewPosition();
+ p.setLocation(0, viewPos.getY());
+ rowHead.setViewPosition(p);
+ }
+ if (colHead != null) {
+ Point2D p = colHead.getPreciseViewPosition();
+ p.setLocation(0, viewPos.getX());
+ colHead.setViewPosition(p);
+ }
+ }
+ ignoreStateChanges = false;
+ }
+
+ /** @return the {@code Handler} for all AWT events this UI needs to process */
+ private Handler getHandler() {
+ if (handler == null) { handler = new Handler(); }
+ return handler;
+ }
+
+ /** Returns a {@code PropertyChangeListener} that will be installed on the {@code ZoomViewport} */
+ private PropertyChangeListener createViewportPropertyChangeListener() { return getHandler(); }
+
+ /** Returns a {@code PropertyChangeListener} that will be installed on the {@code ZoomBar} */
+ private PropertyChangeListener createZBPropertyChangeListener() { return getHandler(); }
+
+ @Override
+ protected ChangeListener createViewportChangeListener() { return getHandler(); }
+
+ @Override
+ protected ChangeListener createVSBChangeListener() { return getHandler(); }
+
+ @Override
+ protected ChangeListener createHSBChangeListener() { return getHandler(); }
+
+ /**
+ * Creates the {@code ChangeListener} for the {@code ZoomBar}'s model.
+ *
+ * @return an instance of the zoom bar {@code ChangeListener}
+ */
+ protected ChangeListener createZBChangeListener() { return getHandler(); }
+
+ /**
+ * Creates the {@code ActionListener} for the {@code ZoomBar}'s fit button.
+ *
+ * @return an instance of the zoom bar {@code ActionListener}
+ */
+ protected ActionListener createZBFitListener() { return getHandler(); }
+
+ /**
+ * {@link BasicScrollPaneUI} adds the returned value from this method to the scroll pane, but since
+ * zooming logic may be relative to the viewport, {@link #createViewportMouseWheelListener()}
+ * creates the listener with the zooming logic for the viewport while this method returns {@code null}.
+ *
+ * @return {@code null}
+ * @see #installUI(JComponent)
+ */
+ @Override
+ protected MouseWheelListener createMouseWheelListener() { return null; }
+
+ /**
+ * Creates an instance of {@code MouseWheelListener}, which is added to the {@code ZoomViewport}
+ * by {@code installUI}. The returned {@code MouseWheelListener} is used to handle
+ * mouse wheel-driven zooming.
+ *
+ * @return {@code MouseWheelListener} which implements wheel-driven zooming
+ * @see #installUI(JComponent)
+ */
+ protected MouseWheelListener createViewportMouseWheelListener() { return getHandler(); }
+
+ @Override
+ protected boolean isSmoothScrollingEnabled() {
+ Object smoothScrolling = scrollpane.getClientProperty("ZoomPane.smoothScrolling");
+ if (smoothScrolling != null) { return Objects.equals(smoothScrolling, true); }
+ smoothScrolling = UIManager.get("ZoomPane.smoothScrolling");
+ if (smoothScrolling != null) { return Objects.equals(smoothScrolling, true); }
+ return super.isSmoothScrollingEnabled();
+ }
+
+ /** @return UI Default {@code ZoomPane.arc} if an integer, otherwise fallback to {@code ScrollPane.arc} */
+ protected int getArc() {
+ Object arc = UIManager.get("ZoomPane.arc");
+ return (arc instanceof Integer) ? (int) arc : UIManager.getInt("ScrollPane.arc");
+ }
+
+ @Override
+ protected void updateViewport(@NotNull PropertyChangeEvent e) {
+ ZoomViewport oldViewport = (ZoomViewport) e.getOldValue();
+ ZoomViewport newViewport = (ZoomViewport) e.getNewValue();
+
+ if (oldViewport != null) {
+ oldViewport.removeMouseWheelListener(mouseZoomListener);
+ oldViewport.removePropertyChangeListener(viewportPropertyChangeListener);
+ oldViewport.removeComponentListener(viewportResizeListener);
+ }
+
+ super.updateViewport(e);
+ if (newViewport != null) {
+ newViewport.addMouseWheelListener(mouseZoomListener);
+ newViewport.addPropertyChangeListener(viewportPropertyChangeListener);
+ newViewport.addComponentListener(viewportResizeListener);
+
+ if (Actions.readyToFit((ZoomPane) scrollpane)) {
+ Actions.zoomToFit((ZoomPane) scrollpane);
+ waitingToFit = false;
+ }
+ else { waitingToFit = true; }
+ }
+ }
+
+ /**
+ * Updates zoom bar.
+ *
+ * @param e the property change event
+ */
+ protected void updateZoomBar(@NotNull PropertyChangeEvent e) {
+ ZoomBar oldZoomBar = (ZoomBar) e.getOldValue();
+ ZoomBar newZoomBar = (ZoomBar) e.getNewValue();
+ JButton fit;
+
+ if (oldZoomBar != null) {
+ oldZoomBar.getModel().removeChangeListener(zbChangeListener);
+ fit = oldZoomBar.getFitButton();
+ if (fit != null) { fit.removeActionListener(zbFitListener); }
+ oldZoomBar.removePropertyChangeListener(zbPropertyChangeListener);
+ }
+ if (newZoomBar != null) {
+ newZoomBar.getModel().addChangeListener(zbChangeListener);
+ fit = newZoomBar.getFitButton();
+ if (fit != null) { fit.addActionListener(zbFitListener); }
+ newZoomBar.addPropertyChangeListener(zbPropertyChangeListener);
+ }
+ }
+
+ /**
+ * Creates an instance of {@code ComponentListener}, which is added to the {@code ZoomViewport}
+ * by {@code installUI}. The returned {@code ComponentListener} adjusts the view position when
+ * the viewport is resized. It also checks if the view is able and needs to be fit to the viewport.
+ *
+ * @return {@code MouseWheelListener} which handles viewport resizing
+ * @see #installUI(JComponent)
+ */
+ protected ComponentListener createViewportResizeListener() { return getHandler(); }
+
+ @Override
+ protected PropertyChangeListener createPropertyChangeListener() {
+ PropertyChangeListener oldPropertyChangeListener = super.createPropertyChangeListener();
+ PropertyChangeListener newPropertyChangeListener = getHandler();
+
+ return e -> {
+ oldPropertyChangeListener.propertyChange(e);
+ newPropertyChangeListener.propertyChange(e);
+ };
+ }
+
+ /** Actions for navigation of the view. */
+ protected static class Actions implements Action {
+
+ protected static final String SCROLL_HOME = "scrollHome";
+ protected static final String UNIT_SCROLL_UP = "unitScrollUp";
+ protected static final String UNIT_SCROLL_DOWN = "unitScrollDown";
+ protected static final String UNIT_SCROLL_LEFT = "unitScrollLeft";
+ protected static final String UNIT_SCROLL_RIGHT = "unitScrollRight";
+ protected static final String SCROLL_UP = "scrollUp";
+ protected static final String SCROLL_DOWN = "scrollDown";
+ protected static final String SCROLL_LEFT = "scrollLeft";
+ protected static final String SCROLL_RIGHT = "scrollRight";
+ protected static final String UNIT_ZOOM_IN = "unitZoomIn";
+ protected static final String UNIT_ZOOM_OUT = "unitZoomOut";
+ protected static final String ZOOM_IN = "zoomIn";
+ protected static final String ZOOM_OUT = "zoomOut";
+
+ /** The {@code Action}'s identifier. */
+ protected String name;
+
+ public Actions(@NotNull String name) { this.name = name; }
+
+ /** @return the {@code Action}'s identifier */
+ public final String getName() { return name; }
+
+ @Override
+ public Object getValue(@NotNull String key) {
+ if (key.equals(NAME)) { return name; }
+ return null;
+ }
+
+ @Override
+ public boolean isEnabled() { return accept(null); }
+
+ @Override
+ public boolean accept(@Nullable Object sender) { return true; }
+
+ // Mutator methods; Actions is immutable and therefore these methods have no implementation
+ @Override public void putValue(String key, Object value) {}
+ @Override public void setEnabled(boolean b) {}
+ @Override public void addPropertyChangeListener(PropertyChangeListener listener) {}
+ @Override public void removePropertyChangeListener(PropertyChangeListener listener) {}
+
+ @Override
+ public void actionPerformed(@NotNull ActionEvent e) {
+ ZoomPane zoomPane = (ZoomPane) e.getSource();
+ boolean ltr = zoomPane.getComponentOrientation().isLeftToRight();
+ String key = getName();
+
+ switch(key) {
+ case SCROLL_HOME -> { zoomToFit(zoomPane); }
+ case UNIT_SCROLL_UP -> { scroll(zoomPane, SwingConstants.VERTICAL, -1, false); }
+ case UNIT_SCROLL_DOWN -> { scroll(zoomPane, SwingConstants.VERTICAL, 1, false); }
+ case UNIT_SCROLL_LEFT -> { scroll(zoomPane, SwingConstants.HORIZONTAL, ltr ? -1 : 1, false); }
+ case UNIT_SCROLL_RIGHT -> { scroll(zoomPane, SwingConstants.HORIZONTAL, ltr ? 1 : -1, false); }
+ case SCROLL_UP -> { scroll(zoomPane, SwingConstants.VERTICAL, -1, true); }
+ case SCROLL_DOWN -> { scroll(zoomPane, SwingConstants.VERTICAL, 1, true); }
+ case SCROLL_LEFT -> { scroll(zoomPane, SwingConstants.HORIZONTAL, ltr ? -1 : 1, true); }
+ case SCROLL_RIGHT -> { scroll(zoomPane, SwingConstants.HORIZONTAL, ltr ? 1 : -1, true); }
+ case UNIT_ZOOM_IN -> { zoom(zoomPane, 1, null); }
+ case UNIT_ZOOM_OUT -> { zoom(zoomPane, -1, null); }
+ case ZOOM_IN -> { zoom(zoomPane, 2.5, null); }
+ case ZOOM_OUT -> { zoom(zoomPane, -2.5, null); }
+ }
+ }
+
+ // ---------------------------------------
+ // Viewport Navigation
+ // ---------------------------------------
+
+ /**
+ * Scroll the view along the horizontal or vertical orientation in some direction.
+ *
+ *
+ * @param s {@inheritDoc}
+ * @param c {@inheritDoc}
+ * @throws IllegalArgumentException {@inheritDoc}
+ */
+ @Override
+ public void addLayoutComponent(@NotNull String s, @Nullable Component c) {
+ if (s.equals(ZOOMBAR)) { zb = (ZoomBar) addSingletonComponent(zb, c); }
+ else { super.addLayoutComponent(s, c); }
+ }
+
+ @Override
+ public void removeLayoutComponent(@NotNull Component c) {
+ if (c == zb) { zb = null; }
+ else { super.removeLayoutComponent(c); }
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ *
+ *
+ * @param parent {@inheritDoc}
+ */
+ @Override
+ public void layoutContainer(@NotNull Container parent) {
+ super.layoutContainer(parent);
+ int zbPolicy = ((ZoomPane) parent).getZoomBarPolicy();
+
+ Rectangle availR = parent.getBounds();
+ Insets insets = parent.getInsets();
+ availR.x = insets.left;
+ availR.y = insets.top;
+ availR.width -= insets.left + insets.right;
+ availR.height -= insets.top + insets.bottom;
+
+ if (zb != null && zbPolicy != ZOOMBAR_NEVER) {
+
+ int zbHeight = zb.getPreferredSize().height;
+ zb.setBounds(new Rectangle(availR.x, availR.height - zbHeight, availR.width, zbHeight));
+
+ if (rowHead != null) {
+ Rectangle rowHeadBounds = rowHead.getBounds();
+ rowHeadBounds.height -= zbHeight;
+ rowHead.setBounds(rowHeadBounds);
+ }
+ if (viewport != null) {
+ Rectangle viewportBounds = viewport.getBounds();
+ viewportBounds.height -= zbHeight;
+ viewport.setBounds(viewportBounds);
+ }
+ if (vsb != null) {
+ Rectangle vsbBounds = vsb.getBounds();
+ vsbBounds.height -= zbHeight;
+ vsb.setBounds(vsbBounds);
+ }
+ if (hsb != null) {
+ Rectangle hsbBounds = hsb.getBounds();
+ hsbBounds.y -= zbHeight;
+ hsb.setBounds(hsbBounds);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/edu/rpi/legup/ui/zoompane/ZoomView.java b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomView.java
new file mode 100644
index 000000000..f0790b0af
--- /dev/null
+++ b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomView.java
@@ -0,0 +1,204 @@
+package edu.rpi.legup.ui.zoompane;
+
+import com.formdev.flatlaf.util.HiDPIUtils;
+import edu.rpi.legup.controller.ZoomViewController;
+import edu.rpi.legup.ui.zoompane.ZoomViewport.ZoomPeer;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import java.awt.*;
+import java.awt.font.GlyphVector;
+import java.awt.geom.Dimension2D;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import javax.swing.event.EventListenerList;
+
+/**
+ * The canvas object that draws an image or environment in a zoomable and interactive environment like a
+ * {@code ZoomPane}. This class maintains its base size and fires a {@code ChangeEvent} when the base size
+ * changes, or it otherwise needs to be repainted. Transformations to the graphics context such as translations
+ * and scaling factors are left to the {@code ZoomView}'s container.
+ */
+public abstract class ZoomView {
+
+ /** A list of event listeners for this {@code ZoomView}. */
+ private final EventListenerList listenerList = new EventListenerList();
+
+ /** Re-usable {@code ChangeEvent} to be fired to {@link ChangeListener}s. */
+ private ChangeEvent changeEvent;
+
+ /** The area needed to draw this view. */
+ private Dimension size = new Dimension();
+
+ /** {@code Component} peer that can be used for displaying popups, directing AWT events, and requesting focus. */
+ private ZoomPeer peer;
+
+ /** {@code ZoomViewController} defining interactive behavior of the view. */
+ private ZoomViewController controller;
+
+ /**
+ * Helper for {@code ZoomView}s that want to draw strings and cannot use
+ * {@link Graphics#drawString} because of its kerning issues.
+ *
+ * @param graphics the graphics context to draw to
+ * @param text the string to be drawn
+ * @param x the x location from which to draw the string
+ * @param y the y location from which to draw the string
+ */
+ public static void drawStringSmooth(@NotNull Graphics2D graphics, @NotNull String text, float x, float y) {
+ graphics.fill(graphics.getFont().createGlyphVector(graphics.getFontRenderContext(), text).getOutline(x, y));
+ }
+
+ /**
+ * Helper for {@code ZoomView}s that want to draw strings and cannot use
+ * {@link Graphics#drawString} because of its kerning issues.
+ *
+ * @param graphics the graphics context to draw to
+ * @param text the string to be drawn
+ * @param cx the center x location from which to draw the string
+ * @param cy the center y location from which to draw the string
+ */
+ public static void drawStringSmoothCentered(@NotNull Graphics2D graphics, @NotNull String text, float cx, float cy) {
+ GlyphVector vector = graphics.getFont().createGlyphVector(graphics.getFontRenderContext(), text);
+ Rectangle2D bounds = vector.getVisualBounds();
+ graphics.fill(vector.getOutline((float) (cx - bounds.getWidth() / 2), (float) (cy - bounds.getHeight() / 2)));
+ }
+
+ /** Creates a {@code ZoomView} with a given controller. */
+ public ZoomView(@Nullable ZoomViewController controller) { setController(controller); }
+
+ /** Gets the area needed to draw this view. */
+ public Dimension getSize() { return size; }
+
+ /**
+ * Updates the size of the view and triggers a repaint.
+ *
+ * @param newSize the new size of the view
+ * @see #fireStateChanged
+ */
+ public void setSize(@NotNull Dimension newSize) {
+ if (!size.equals(newSize)) {
+ size = newSize;
+ fireStateChanged();
+ }
+ }
+
+ /**
+ * Sets the {@code ZoomView}'s {@code Component} peer. The peer is used for creating popups and
+ * capturing user input and focus.
+ *
+ * @param newPeer the new peer to use
+ */
+ public void setPeer(@Nullable ZoomPeer newPeer) {
+
+ if (peer != null && controller != null) {
+ peer.removeZoomViewController(controller);
+ peer.removeKeyListener(controller);
+ }
+ peer = newPeer;
+ if (peer != null && controller != null) {
+ peer.addZoomViewController(controller);
+ peer.addKeyListener(controller);
+ }
+ }
+
+ /** @return the {@code ZoomView}'s {@code Component} peer */
+ public ZoomPeer getPeer() { return peer; }
+
+ /**
+ * Sets the {@code ZoomViewController} used by the {@code ZoomView} to handle user input.
+ *
+ * @param newController the new controller to use
+ */
+ public void setController(@Nullable ZoomViewController newController) {
+
+ if (controller != null) {
+ controller.setView(null);
+
+ if (peer != null) {
+ peer.removeZoomViewController(controller);
+ peer.removeKeyListener(controller);
+ }
+ }
+
+ controller = newController;
+
+ if (controller != null) {
+ controller.setView(this);
+
+ if (peer != null) {
+ peer.addZoomViewController(controller);
+ peer.addKeyListener(controller);
+ }
+ }
+ }
+
+ /** @return the {@code ZoomView}'s controller */
+ public ZoomViewController getController() { return controller; }
+
+ /**
+ * Adds a {@code ChangeListener} to the list that is notified each time the view's size changes.
+ * If listener {@code l} is {@code null}, no exception is thrown and no action is performed.
+ *
+ * @param l the {@code ChangeListener} to add
+ * @see #removeChangeListener
+ */
+ public void addChangeListener(@Nullable ChangeListener l) {
+ if (l != null) { listenerList.add(ChangeListener.class, l); }
+ }
+
+ /**
+ * Removes a {@code ChangeListener} from the list that's notified each time the view's size changes.
+ * If listener {@code l} has not previously been added to this view or is {@code null}, no exception
+ * is thrown and no action is performed.
+ *
+ * @param l the {@code ChangeListener} to remove
+ * @see #addChangeListener
+ */
+ public void removeChangeListener(ChangeListener l) {
+ if (l != null) { listenerList.remove(ChangeListener.class, l); }
+ }
+
+ /**
+ * Returns an array of all the {@code ChangeListener}s added to this {@code ZoomView} with
+ * {@link #addChangeListener}.
+ *
+ * @return all of the {@code ChangeListener}s added or an empty array if no listeners have been added
+ */
+ public ChangeListener[] getChangeListeners() { return listenerList.getListeners(ChangeListener.class); }
+
+ /** Alerts all {@link ChangeListener}s that the view's state has changed. */
+ private void fireStateChanged() {
+ if (changeEvent == null) { changeEvent = new ChangeEvent(this); }
+ for (ChangeListener listener : getChangeListeners()) { listener.stateChanged(changeEvent); }
+ }
+
+ /** Trigger a repaint of the {@code ZoomView} as soon as possible. */
+ public void repaint() { getPeer().repaint(); }
+
+ /** Trigger a repaint of the {@code ZoomView} contained to the given {@code Rectangle2D} as soon as possible. */
+ public void repaint(@NotNull Rectangle2D r) { repaint(r.getX(), r.getY(), r.getWidth(), r.getHeight()); }
+
+ /** Trigger a repaint of the {@code ZoomView} contained to the given bounds as soon as possible. */
+ public void repaint(double x, double y, double width, double height) {
+ Point2D p = getPeer().fromViewCoordinates(new Point2D.Double(x, y));
+ Dimension2D d = getPeer().fromViewCoordinates(new ZoomViewport.Dimension2DDouble(width, height));
+
+ HiDPIUtils.repaint(
+ getPeer(),
+ (int) Math.floor(p.getX()),
+ (int) Math.floor(p.getY()),
+ (int) (Math.ceil(p.getX() + d.getWidth()) - Math.floor(p.getX())),
+ (int) (Math.ceil(p.getY() + d.getHeight()) - Math.floor(p.getY()))
+ );
+ }
+
+ /**
+ * Draws this view's content to the given graphics context at its base scale.
+ *
+ * @param graphics the zoomed graphics context to draw to
+ */
+ public abstract void draw(@NotNull Graphics graphics);
+}
\ No newline at end of file
diff --git a/src/main/java/edu/rpi/legup/ui/zoompane/ZoomViewport.java b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomViewport.java
new file mode 100644
index 000000000..126de6e8a
--- /dev/null
+++ b/src/main/java/edu/rpi/legup/ui/zoompane/ZoomViewport.java
@@ -0,0 +1,815 @@
+package edu.rpi.legup.ui.zoompane;
+
+import edu.rpi.legup.controller.ZoomViewController;
+import edu.rpi.legup.controller.ZoomViewController.ControllerMouseEvent;
+import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
+
+import javax.accessibility.Accessible;
+import javax.swing.*;
+import javax.swing.event.ChangeEvent;
+import javax.swing.event.ChangeListener;
+import javax.swing.event.MouseInputAdapter;
+import javax.swing.event.MouseInputListener;
+import java.awt.*;
+import java.awt.event.*;
+import java.awt.geom.Dimension2D;
+import java.awt.geom.Point2D;
+import java.awt.geom.Rectangle2D;
+import java.beans.BeanProperty;
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+/**
+ * The "viewport" through which you can see the underlying {@link ZoomView}. When you pan and zoom, what
+ * performs the translation and scaling is the {@code ZoomViewport}. It is like peering through a camera's
+ * viewfinder: the {@code ZoomViewport}'s position and size stay grounded within its parent while the
+ * {@link ZoomView} displayed within its bounds is shifted around and scaled.
+ *
+ * zoomPane.getZoomBar().putClientProperty("ZoomBar.fastWheelScrolling", Boolean.FALSE);
+ *
+ *
+ * @see JViewport
+ * @see ZoomView
+ * @see ZoomPane
+ */
+public class ZoomViewport extends JViewport implements Accessible {
+
+ /** Listener that is notified each time the view changes size. */
+ private ChangeListener viewListener = null;
+
+ /** The view element that is being displayed by the {@code ZoomViewport}. */
+ protected ZoomView view;
+
+ /** The scale factor being applied to the drawing of the view. */
+ protected double scaleFactor;
+
+ /** The current position of the view in viewport coordinates. */
+ protected Point2D viewPosition;
+
+ /** Creates a {@code ZoomViewport}. */
+ public ZoomViewport() {
+ super();
+ setScrollMode(SIMPLE_SCROLL_MODE);
+ setPeer(createPeer());
+ }
+
+ /**
+ * Scrolls the view so that the {@code Rectangle2D} in view coordinates becomes visible. If
+ * {@code contentRect} is bigger than the current view area, the viewport will zoom out to
+ * accommodate the {@code contentRect}.
+ *
+ * @param contentRect the {@code Rectangle2D} to display.
+ * @see #setViewRect
+ */
+ public void scrollRectToVisible(@NotNull Rectangle2D contentRect) {
+ if (view == null) { return; }
+
+ Rectangle2D viewRect = getViewRect();
+ if (viewRect.contains(contentRect)) { return; }
+
+ double widthRatio = viewRect.getWidth() / contentRect.getWidth();
+ double heightRatio = viewRect.getHeight() / contentRect.getHeight();
+ double minRatio = Math.min(widthRatio, heightRatio);
+
+ // Zoom out if necessary
+ if (minRatio < 1.0) { scaleFactor *= minRatio; }
+
+ // Adjust view position to fit as much of the contentRect inside as possible
+ Point2D viewPos = new Point2D.Double(viewRect.getX(), viewRect.getY());
+ if ((viewRect.getX() < contentRect.getX()) !=
+ (viewRect.getX() + viewRect.getWidth() > contentRect.getX() + contentRect.getWidth())) {
+ double leftEdgesDist = contentRect.getX() - viewRect.getX();
+ double rightEdgesDist = (contentRect.getX() + contentRect.getWidth()) -
+ (viewRect.getX() + viewRect.getWidth());
+ viewPos.setLocation(
+ viewPos.getX() +
+ Math.abs(leftEdgesDist) < Math.abs(rightEdgesDist) ? leftEdgesDist : rightEdgesDist,
+ viewPos.getY());
+ }
+ if ((viewRect.getY() < contentRect.getY()) !=
+ (viewRect.getY() + viewRect.getHeight() > contentRect.getY() + contentRect.getHeight())) {
+ double topEdgesDist = contentRect.getY() - viewRect.getY();
+ double bottomEdgesDist = (contentRect.getY() + contentRect.getHeight()) -
+ (viewRect.getY() + viewRect.getHeight());
+ viewPos.setLocation(
+ viewPos.getY() +
+ Math.abs(topEdgesDist) < Math.abs(bottomEdgesDist) ? topEdgesDist : bottomEdgesDist,
+ viewPos.getY());
+ }
+
+ setViewPosition(viewPos);
+ }
+
+ /**
+ * Scrolls the view so that the {@code Rectangle} in view coordinates becomes visible. If
+ * {@code contentRect} is bigger than the current view area, the viewport will zoom
+ * out to accommodate the {@code contentRect}.
+ *
+ * @param contentRect {@inheritDoc}
+ * @see #setViewRect(Rectangle2D)
+ */
+ @Override
+ public void scrollRectToVisible(@NotNull Rectangle contentRect) { scrollRectToVisible((Rectangle2D) contentRect); }
+
+ /**
+ * Paints this {@code ZoomViewport}'s children. Since {@code ZoomViewport} is intended to be the
+ * viewport to a {@link ZoomView}, the view is drawn here, before its peer.
+ *
+ * @param graphics the {@code Graphics} context to draw in
+ */
+ public void paintChildren(@NotNull Graphics graphics) {
+
+ Graphics2D g = (Graphics2D) graphics.create();
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
+ g.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
+
+ g.scale(scaleFactor, scaleFactor);
+ g.translate(-viewPosition.getX(), -viewPosition.getY());
+
+ view.draw(g);
+ g.dispose();
+
+ super.paintChildren(graphics);
+ }
+
+ @Override
+ public void validate() {
+ super.validate();
+ fireStateChanged();
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @param mode must have value {@code ZoomViewport.SIMPLE_SCROLL_MODE}
+ * @throws IllegalArgumentException if mode is not {@code ZoomViewport.SIMPLE_SCROLL_MODE}
+ */
+ @Override
+ @BeanProperty(bound = false, enumerationValues = {"ZoomViewport.SIMPLE_SCROLL_MODE"},
+ description = "Method of moving contents for incremental scrolls.")
+ public void setScrollMode(int mode) {
+ if (mode == SIMPLE_SCROLL_MODE) { super.setScrollMode(mode); }
+ else {
+ throw new IllegalArgumentException("ZoomViewport does not support BLIT and BACKING_STORE scroll modes.");
+ }
+ }
+
+ /**
+ * Returns the {@code ZoomViewport}'s component view or {@code null}.
+ *
+ * @return {@inheritDoc}
+ * @see #setView(Component)
+ * @see #getPeer()
+ */
+ @Override
+ public Component getView() { return super.getView(); }
+
+ /**
+ * Returns the {@code ZoomViewport}'s view or {@code null}.
+ *
+ * @return the viewport's view, or {@code null} if none exists
+ * @see #setZoomView(ZoomView)
+ */
+ public ZoomView getZoomView() { return view; }
+
+ /**
+ * Sets the {@code ZoomViewport}'s component view, which can be {@code null}.
+ *
+ * @param view the viewport's new component view
+ * @throws IllegalArgumentException if view is not a {@code ZoomPeer}
+ * @see ZoomPeer
+ * @see #setPeer(ZoomPeer)
+ */
+ @Override
+ public void setView(@Nullable Component view) {
+ if (view instanceof ZoomPeer || view == null) { super.setView(view); }
+ else { throw new IllegalArgumentException("ZoomViewport only permits a ZoomPeer view."); }
+ }
+
+ /**
+ * Set the {@code ZoomViewport}'s view, which can be {@code null}.
+ *
+ * @param newView the {@code ZoomView} to be displayed, or {@code null}
+ * @see #getView()
+ */
+ @BeanProperty(preferred = true, description = "The viewport view.")
+ public void setZoomView(@Nullable ZoomView newView) {
+ if (view == newView) { return; }
+
+ if (view != null) {
+ view.removeChangeListener(viewListener);
+ view.setPeer(null);
+ }
+
+ ZoomView oldView = view;
+ view = newView;
+ scaleFactor = 1.0;
+ viewPosition = new Point2D.Double();
+
+ if (view != null) {
+ viewListener = (ChangeListener) createViewListener();
+ view.addChangeListener(viewListener);
+ view.setPeer(getPeer());
+ }
+
+ firePropertyChange("view", oldView, newView);
+ repaint();
+ }
+
+ /**
+ * Returns the current scale factor being applied to the view's drawing.
+ *
+ * @return the current scale factor
+ */
+ public double getScaleFactor() { return scaleFactor; }
+
+ /**
+ * Sets the current scale factor being applied to the view's drawing, then fires a state change.
+ *
+ * @param newScaleFactor the new scale factor to be applied to the view's drawing
+ * @throws IllegalArgumentException if {@code newScaleFactor <= 0}
+ */
+ public void setScaleFactor(double newScaleFactor) {
+ if (newScaleFactor <= 0) { throw new IllegalArgumentException("newScaleFactor must be greater than 0."); }
+ if (view == null || newScaleFactor == scaleFactor) { return; }
+
+ scaleFactor = newScaleFactor;
+ fireStateChanged();
+ repaint();
+ }
+
+ /**
+ * Returns the size of the view in view coordinates. If there is no view, return {@code (0,0)}.
+ *
+ * @return {@inheritDoc}
+ */
+ @Override
+ public Dimension getViewSize() {
+ if (view == null) { return new Dimension(); }
+ return view.getSize();
+ }
+
+ @Override
+ public void setViewSize(@NotNull Dimension newSize) {
+ ZoomView view = getZoomView();
+ if (view != null && !view.getSize().equals(newSize)) { view.setSize(newSize); }
+ }
+
+ /**
+ * Returns the view coordinates that appear in the upper left hand corner of the viewport in view
+ * coordinates, or {@code (0,0)} if there's no view.
+ *
+ * @return {@inheritDoc}
+ * @see #getPreciseViewPosition()
+ */
+ @Override
+ public Point getViewPosition() {
+ Point2D precisePos = getPreciseViewPosition();
+ return new Point((int) Math.floor(precisePos.getX()), (int) Math.floor(precisePos.getY()));
+ }
+
+ /**
+ * Returns the view coordinates that appear in the upper left hand corner of the viewport in view
+ * coordinates, or {@code (0,0)} if there's no view.
+ *
+ * @return a {@code Point2D} object giving the upper left coordinates
+ */
+ public Point2D getPreciseViewPosition() {
+ if (view == null) { return new Point2D.Double(); }
+ return toViewCoordinates(new Point2D.Double());
+ }
+
+ /**
+ * {@inheritDoc}
+ *