1/*
2 * Copyright (c) 2011, 2014, Oracle and/or its affiliates. All rights reserved.
3 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER.
4 *
5 * This code is free software; you can redistribute it and/or modify it
6 * under the terms of the GNU General Public License version 2 only, as
7 * published by the Free Software Foundation.  Oracle designates this
8 * particular file as subject to the "Classpath" exception as provided
9 * by Oracle in the LICENSE file that accompanied this code.
10 *
11 * This code is distributed in the hope that it will be useful, but WITHOUT
12 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13 * FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
14 * version 2 for more details (a copy is included in the LICENSE file that
15 * accompanied this code).
16 *
17 * You should have received a copy of the GNU General Public License version
18 * 2 along with this work; if not, write to the Free Software Foundation,
19 * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA.
20 *
21 * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA
22 * or visit www.oracle.com if you need additional information or have any
23 * questions.
24 */
25
26package com.apple.laf;
27
28import java.awt.*;
29import java.awt.event.*;
30import java.beans.*;
31
32import javax.swing.*;
33import javax.swing.event.MouseInputAdapter;
34import javax.swing.plaf.*;
35import javax.swing.plaf.basic.BasicTreeUI;
36import javax.swing.tree.*;
37
38import com.apple.laf.AquaUtils.RecyclableSingleton;
39
40import apple.laf.*;
41import apple.laf.JRSUIConstants.*;
42import apple.laf.JRSUIState.AnimationFrameState;
43
44/**
45 * AquaTreeUI supports the client property "value-add" system of customization See MetalTreeUI
46 * This is heavily based on the 1.3.1 AquaTreeUI implementation.
47 */
48public class AquaTreeUI extends BasicTreeUI {
49
50    // Create PLAF
51    public static ComponentUI createUI(final JComponent c) {
52        return new AquaTreeUI();
53    }
54
55    // Begin Line Stuff from Metal
56
57    private static final String LINE_STYLE = "JTree.lineStyle";
58
59    private static final String LEG_LINE_STYLE_STRING = "Angled";
60    private static final String HORIZ_STYLE_STRING = "Horizontal";
61    private static final String NO_STYLE_STRING = "None";
62
63    private static final int LEG_LINE_STYLE = 2;
64    private static final int HORIZ_LINE_STYLE = 1;
65    private static final int NO_LINE_STYLE = 0;
66
67    private int lineStyle = HORIZ_LINE_STYLE;
68    private final PropertyChangeListener lineStyleListener = new LineListener();
69
70    // mouse tracking state
71    protected TreePath fTrackingPath;
72    protected boolean fIsPressed = false;
73    protected boolean fIsInBounds = false;
74    protected int fAnimationFrame = -1;
75    protected TreeArrowMouseInputHandler fMouseHandler;
76
77    protected final AquaPainter<AnimationFrameState> painter = AquaPainter.create(JRSUIStateFactory.getDisclosureTriangle());
78
79    public AquaTreeUI() {
80
81    }
82
83    public void installUI(final JComponent c) {
84        super.installUI(c);
85
86        final Object lineStyleFlag = c.getClientProperty(LINE_STYLE);
87        decodeLineStyle(lineStyleFlag);
88        c.addPropertyChangeListener(lineStyleListener);
89    }
90
91    public void uninstallUI(final JComponent c) {
92        c.removePropertyChangeListener(lineStyleListener);
93        super.uninstallUI(c);
94    }
95
96    /**
97     * Creates the focus listener to repaint the focus ring
98     */
99    protected FocusListener createFocusListener() {
100        return new AquaTreeUI.FocusHandler();
101    }
102
103    /**
104     * this function converts between the string passed into the client property and the internal representation
105     * (currently an int)
106     */
107    protected void decodeLineStyle(final Object lineStyleFlag) {
108        if (lineStyleFlag == null || NO_STYLE_STRING.equals(lineStyleFlag)) {
109            lineStyle = NO_LINE_STYLE; // default case
110            return;
111        }
112
113        if (LEG_LINE_STYLE_STRING.equals(lineStyleFlag)) {
114            lineStyle = LEG_LINE_STYLE;
115        } else if (HORIZ_STYLE_STRING.equals(lineStyleFlag)) {
116            lineStyle = HORIZ_LINE_STYLE;
117        }
118    }
119
120    public TreePath getClosestPathForLocation(final JTree treeLocal, final int x, final int y) {
121        if (treeLocal == null || treeState == null) return null;
122
123        Insets i = treeLocal.getInsets();
124        if (i == null) i = new Insets(0, 0, 0, 0);
125        return treeState.getPathClosestTo(x - i.left, y - i.top);
126    }
127
128    public void paint(final Graphics g, final JComponent c) {
129        super.paint(g, c);
130
131        // Paint the lines
132        if (lineStyle == HORIZ_LINE_STYLE && !largeModel) {
133            paintHorizontalSeparators(g, c);
134        }
135    }
136
137    protected void paintHorizontalSeparators(final Graphics g, final JComponent c) {
138        g.setColor(UIManager.getColor("Tree.line"));
139
140        final Rectangle clipBounds = g.getClipBounds();
141
142        final int beginRow = getRowForPath(tree, getClosestPathForLocation(tree, 0, clipBounds.y));
143        final int endRow = getRowForPath(tree, getClosestPathForLocation(tree, 0, clipBounds.y + clipBounds.height - 1));
144
145        if (beginRow <= -1 || endRow <= -1) { return; }
146
147        for (int i = beginRow; i <= endRow; ++i) {
148            final TreePath path = getPathForRow(tree, i);
149
150            if (path != null && path.getPathCount() == 2) {
151                final Rectangle rowBounds = getPathBounds(tree, getPathForRow(tree, i));
152
153                // Draw a line at the top
154                if (rowBounds != null) g.drawLine(clipBounds.x, rowBounds.y, clipBounds.x + clipBounds.width, rowBounds.y);
155            }
156        }
157    }
158
159    protected void paintVerticalPartOfLeg(final Graphics g, final Rectangle clipBounds, final Insets insets, final TreePath path) {
160        if (lineStyle == LEG_LINE_STYLE) {
161            super.paintVerticalPartOfLeg(g, clipBounds, insets, path);
162        }
163    }
164
165    protected void paintHorizontalPartOfLeg(final Graphics g, final Rectangle clipBounds, final Insets insets, final Rectangle bounds, final TreePath path, final int row, final boolean isExpanded, final boolean hasBeenExpanded, final boolean isLeaf) {
166        if (lineStyle == LEG_LINE_STYLE) {
167            super.paintHorizontalPartOfLeg(g, clipBounds, insets, bounds, path, row, isExpanded, hasBeenExpanded, isLeaf);
168        }
169    }
170
171    /** This class listens for changes in line style */
172    class LineListener implements PropertyChangeListener {
173        public void propertyChange(final PropertyChangeEvent e) {
174            final String name = e.getPropertyName();
175            if (name.equals(LINE_STYLE)) {
176                decodeLineStyle(e.getNewValue());
177            }
178        }
179    }
180
181    /**
182     * Paints the expand (toggle) part of a row. The receiver should NOT modify {@code clipBounds}, or
183     * {@code insets}.
184     */
185    protected void paintExpandControl(final Graphics g, final Rectangle clipBounds, final Insets insets, final Rectangle bounds, final TreePath path, final int row, final boolean isExpanded, final boolean hasBeenExpanded, final boolean isLeaf) {
186        final Object value = path.getLastPathComponent();
187
188        // Draw icons if not a leaf and either hasn't been loaded,
189        // or the model child count is > 0.
190        if (isLeaf || (hasBeenExpanded && treeModel.getChildCount(value) <= 0)) return;
191
192        final boolean isLeftToRight = AquaUtils.isLeftToRight(tree); // Basic knows, but keeps it private
193
194        final State state = getState(path);
195
196        // if we are not animating, do the expected thing, and use the icon
197        // also, if there is a custom (non-LaF defined) icon - just use that instead
198        if (fAnimationFrame == -1 && state != State.PRESSED) {
199            super.paintExpandControl(g, clipBounds, insets, bounds, path, row, isExpanded, hasBeenExpanded, isLeaf);
200            return;
201        }
202
203        // Both icons are the same size
204        final Icon icon = isExpanded ? getExpandedIcon() : getCollapsedIcon();
205        if (!(icon instanceof UIResource)) {
206            super.paintExpandControl(g, clipBounds, insets, bounds, path, row, isExpanded, hasBeenExpanded, isLeaf);
207            return;
208        }
209
210        // if painting a right-to-left knob, we ensure that we are only painting when
211        // the clipbounds rect is set to the exact size of the knob, and positioned correctly
212        // (this code is not the same as metal)
213        int middleXOfKnob;
214        if (isLeftToRight) {
215            middleXOfKnob = bounds.x - (getRightChildIndent() - 1);
216        } else {
217            middleXOfKnob = clipBounds.x + clipBounds.width / 2;
218        }
219
220        // Center vertically
221        final int middleYOfKnob = bounds.y + (bounds.height / 2);
222
223        final int x = middleXOfKnob - icon.getIconWidth() / 2;
224        final int y = middleYOfKnob - icon.getIconHeight() / 2;
225        final int height = icon.getIconHeight(); // use the icon height so we don't get drift  we modify the bounds (by changing row height)
226        final int width = 20; // this is a hardcoded value from our default icon (since we are only at this point for animation)
227
228        setupPainter(state, isExpanded, isLeftToRight);
229        painter.paint(g, tree, x, y, width, height);
230    }
231
232    @Override
233    public Icon getCollapsedIcon() {
234        final Icon icon = super.getCollapsedIcon();
235        if (AquaUtils.isLeftToRight(tree)) return icon;
236        if (!(icon instanceof UIResource)) return icon;
237        return UIManager.getIcon("Tree.rightToLeftCollapsedIcon");
238    }
239
240    protected void setupPainter(State state, final boolean isExpanded, final boolean leftToRight) {
241        if (!fIsInBounds && state == State.PRESSED) state = State.ACTIVE;
242
243        painter.state.set(state);
244        if (JRSUIUtils.Tree.useLegacyTreeKnobs()) {
245            if (fAnimationFrame == -1) {
246                painter.state.set(isExpanded ? Direction.DOWN : Direction.RIGHT);
247            } else {
248                painter.state.set(Direction.NONE);
249                painter.state.setAnimationFrame(fAnimationFrame - 1);
250            }
251        } else {
252            painter.state.set(getDirection(isExpanded, leftToRight));
253            painter.state.setAnimationFrame(fAnimationFrame);
254        }
255    }
256
257    protected Direction getDirection(final boolean isExpanded, final boolean isLeftToRight) {
258        if (isExpanded && (fAnimationFrame == -1)) return Direction.DOWN;
259        return isLeftToRight ? Direction.RIGHT : Direction.LEFT;
260    }
261
262    protected State getState(final TreePath path) {
263        if (!tree.isEnabled()) return State.DISABLED;
264        if (fIsPressed) {
265            if (fTrackingPath.equals(path)) return State.PRESSED;
266        }
267        return State.ACTIVE;
268    }
269
270    /**
271     * Misnamed - this is called on mousePressed Macs shouldn't react till mouseReleased
272     * We install a motion handler that gets removed after.
273     * See super.MouseInputHandler & super.startEditing for why
274     */
275    protected void handleExpandControlClick(final TreePath path, final int mouseX, final int mouseY) {
276        fMouseHandler = new TreeArrowMouseInputHandler(path);
277    }
278
279    /**
280     * Returning true signifies a mouse event on the node should toggle the selection of only the row under mouse.
281     */
282    protected boolean isToggleSelectionEvent(final MouseEvent event) {
283        return SwingUtilities.isLeftMouseButton(event) && event.isMetaDown();
284    }
285
286    class FocusHandler extends BasicTreeUI.FocusHandler {
287        public void focusGained(final FocusEvent e) {
288            super.focusGained(e);
289            AquaBorder.repaintBorder(tree);
290        }
291
292        public void focusLost(final FocusEvent e) {
293            super.focusLost(e);
294            AquaBorder.repaintBorder(tree);
295        }
296    }
297
298    protected PropertyChangeListener createPropertyChangeListener() {
299        return new MacPropertyChangeHandler();
300    }
301
302    public class MacPropertyChangeHandler extends PropertyChangeHandler {
303        public void propertyChange(final PropertyChangeEvent e) {
304            final String prop = e.getPropertyName();
305            if (prop.equals(AquaFocusHandler.FRAME_ACTIVE_PROPERTY)) {
306                AquaBorder.repaintBorder(tree);
307                AquaFocusHandler.swapSelectionColors("Tree", tree, e.getNewValue());
308            } else {
309                super.propertyChange(e);
310            }
311        }
312    }
313
314    /**
315     * TreeArrowMouseInputHandler handles passing all mouse events the way a Mac should - hilite/dehilite on enter/exit,
316     * only perform the action if released in arrow.
317     *
318     * Just like super.MouseInputHandler, this is removed once it's not needed, so they won't clash with each other
319     */
320    // The Adapters take care of defining all the empties
321    class TreeArrowMouseInputHandler extends MouseInputAdapter {
322        protected Rectangle fPathBounds = new Rectangle();
323
324        // Values needed for paintOneControl
325        protected boolean fIsLeaf, fIsExpanded, fHasBeenExpanded;
326        protected Rectangle fBounds, fVisibleRect;
327        int fTrackingRow;
328        Insets fInsets;
329        Color fBackground;
330
331        TreeArrowMouseInputHandler(final TreePath path) {
332            fTrackingPath = path;
333            fIsPressed = true;
334            fIsInBounds = true;
335            this.fPathBounds = getPathArrowBounds(path);
336            tree.addMouseListener(this);
337            tree.addMouseMotionListener(this);
338            fBackground = tree.getBackground();
339            if (!tree.isOpaque()) {
340                final Component p = tree.getParent();
341                if (p != null) fBackground = p.getBackground();
342            }
343
344            // Set up values needed to paint the triangle - see
345            // BasicTreeUI.paint
346            fVisibleRect = tree.getVisibleRect();
347            fInsets = tree.getInsets();
348
349            if (fInsets == null) fInsets = new Insets(0, 0, 0, 0);
350            fIsLeaf = treeModel.isLeaf(path.getLastPathComponent());
351            if (fIsLeaf) fIsExpanded = fHasBeenExpanded = false;
352            else {
353                fIsExpanded = treeState.getExpandedState(path);
354                fHasBeenExpanded = tree.hasBeenExpanded(path);
355            }
356            final Rectangle boundsBuffer = new Rectangle();
357            fBounds = treeState.getBounds(fTrackingPath, boundsBuffer);
358            fBounds.x += fInsets.left;
359            fBounds.y += fInsets.top;
360            fTrackingRow = getRowForPath(fTrackingPath);
361
362            paintOneControl();
363        }
364
365        public void mouseDragged(final MouseEvent e) {
366            fIsInBounds = fPathBounds.contains(e.getX(), e.getY());
367                paintOneControl();
368            }
369
370        @Override
371        public void mouseExited(MouseEvent e) {
372            fIsInBounds = fPathBounds.contains(e.getX(), e.getY());
373            paintOneControl();
374        }
375
376        public void mouseReleased(final MouseEvent e) {
377            if (tree == null) return;
378
379            if (fIsPressed) {
380                final boolean wasInBounds = fIsInBounds;
381
382                fIsPressed = false;
383                fIsInBounds = false;
384
385                if (wasInBounds) {
386                    fIsExpanded = !fIsExpanded;
387                    paintAnimation(fIsExpanded);
388                    if (e.isAltDown()) {
389                        if (fIsExpanded) {
390                            expandNode(fTrackingRow, true);
391                        } else {
392                            collapseNode(fTrackingRow, true);
393                        }
394                    } else {
395                        toggleExpandState(fTrackingPath);
396                    }
397                }
398            }
399            fTrackingPath = null;
400            removeFromSource();
401        }
402
403        protected void paintAnimation(final boolean expanding) {
404            if (expanding) {
405                paintAnimationFrame(1);
406                paintAnimationFrame(2);
407                paintAnimationFrame(3);
408            } else {
409                paintAnimationFrame(3);
410                paintAnimationFrame(2);
411                paintAnimationFrame(1);
412            }
413            fAnimationFrame = -1;
414        }
415
416        protected void paintAnimationFrame(final int frame) {
417            fAnimationFrame = frame;
418            paintOneControl();
419            try { Thread.sleep(20); } catch (final InterruptedException e) { }
420        }
421
422        // Utility to paint just one widget while it's being tracked
423        // Just doing "repaint" runs into problems if someone does "translate" on the graphics
424        // (ie, Sun's JTreeTable example, which is used by Moneydance - see Radar 2697837)
425        void paintOneControl() {
426            if (tree == null) return;
427            final Graphics g = tree.getGraphics();
428            if (g == null) {
429                // i.e. source is not displayable
430                return;
431            }
432
433            try {
434                g.setClip(fVisibleRect);
435                // If we ever wanted a callback for drawing the arrow between
436                // transition stages
437                // the code between here and paintExpandControl would be it
438                g.setColor(fBackground);
439                g.fillRect(fPathBounds.x, fPathBounds.y, fPathBounds.width, fPathBounds.height);
440
441                // if there is no tracking path, we don't need to paint anything
442                if (fTrackingPath == null) return;
443
444                // draw the vertical line to the parent
445                final TreePath parentPath = fTrackingPath.getParentPath();
446                if (parentPath != null) {
447                    paintVerticalPartOfLeg(g, fPathBounds, fInsets, parentPath);
448                    paintHorizontalPartOfLeg(g, fPathBounds, fInsets, fBounds, fTrackingPath, fTrackingRow, fIsExpanded, fHasBeenExpanded, fIsLeaf);
449                } else if (isRootVisible() && fTrackingRow == 0) {
450                    paintHorizontalPartOfLeg(g, fPathBounds, fInsets, fBounds, fTrackingPath, fTrackingRow, fIsExpanded, fHasBeenExpanded, fIsLeaf);
451                }
452                paintExpandControl(g, fPathBounds, fInsets, fBounds, fTrackingPath, fTrackingRow, fIsExpanded, fHasBeenExpanded, fIsLeaf);
453            } finally {
454                g.dispose();
455            }
456        }
457
458        protected void removeFromSource() {
459            tree.removeMouseListener(this);
460            tree.removeMouseMotionListener(this);
461            }
462        }
463
464    protected int getRowForPath(final TreePath path) {
465        return treeState.getRowForPath(path);
466    }
467
468    /**
469     * see isLocationInExpandControl for bounds calc
470     */
471    protected Rectangle getPathArrowBounds(final TreePath path) {
472        final Rectangle bounds = getPathBounds(tree, path); // Gives us the y values, but x is adjusted for the contents
473        final Insets i = tree.getInsets();
474
475        if (getExpandedIcon() != null) bounds.width = getExpandedIcon().getIconWidth();
476        else bounds.width = 8;
477
478        int boxLeftX = (i != null) ? i.left : 0;
479        if (AquaUtils.isLeftToRight(tree)) {
480            boxLeftX += (((path.getPathCount() + depthOffset - 2) * totalChildIndent) + getLeftChildIndent()) - bounds.width / 2;
481        } else {
482            boxLeftX += tree.getWidth() - 1 - ((path.getPathCount() - 2 + depthOffset) * totalChildIndent) - getLeftChildIndent() - bounds.width / 2;
483        }
484        bounds.x = boxLeftX;
485        return bounds;
486    }
487
488    protected void installKeyboardActions() {
489        super.installKeyboardActions();
490        tree.getActionMap().put("aquaExpandNode", new KeyboardExpandCollapseAction(true, false));
491        tree.getActionMap().put("aquaCollapseNode", new KeyboardExpandCollapseAction(false, false));
492        tree.getActionMap().put("aquaFullyExpandNode", new KeyboardExpandCollapseAction(true, true));
493        tree.getActionMap().put("aquaFullyCollapseNode", new KeyboardExpandCollapseAction(false, true));
494    }
495
496    @SuppressWarnings("serial") // Superclass is not serializable across versions
497    class KeyboardExpandCollapseAction extends AbstractAction {
498        /**
499         * Determines direction to traverse, 1 means expand, -1 means collapse.
500         */
501        final boolean expand;
502        final boolean recursive;
503
504        /**
505         * True if the selection is reset, false means only the lead path changes.
506         */
507        public KeyboardExpandCollapseAction(final boolean expand, final boolean recursive) {
508            this.expand = expand;
509            this.recursive = recursive;
510        }
511
512        public void actionPerformed(final ActionEvent e) {
513            if (tree == null || 0 > getRowCount(tree)) return;
514
515            final TreePath[] selectionPaths = tree.getSelectionPaths();
516            if (selectionPaths == null) return;
517
518            for (int i = selectionPaths.length - 1; i >= 0; i--) {
519                final TreePath path = selectionPaths[i];
520
521                /*
522                 * Try and expand the node, otherwise go to next node.
523                 */
524                if (expand) {
525                    expandNode(tree.getRowForPath(path), recursive);
526                    continue;
527                }
528                // else collapse
529
530                // in the special case where there is only one row selected,
531                // we want to do what the Cocoa does, and select the parent
532                if (selectionPaths.length == 1 && tree.isCollapsed(path)) {
533                    final TreePath parentPath = path.getParentPath();
534                    if (parentPath != null && (!(parentPath.getParentPath() == null) || tree.isRootVisible())) {
535                        tree.scrollPathToVisible(parentPath);
536                        tree.setSelectionPath(parentPath);
537                    }
538                    continue;
539                }
540
541                collapseNode(tree.getRowForPath(path), recursive);
542            }
543        }
544
545        public boolean isEnabled() {
546            return (tree != null && tree.isEnabled());
547        }
548    }
549
550    void expandNode(final int row, final boolean recursive) {
551        final TreePath path = getPathForRow(tree, row);
552        if (path == null) return;
553
554        tree.expandPath(path);
555        if (!recursive) return;
556
557        expandAllNodes(path, row + 1);
558    }
559
560    void expandAllNodes(final TreePath parent, final int initialRow) {
561        for (int i = initialRow; true; i++) {
562            final TreePath path = getPathForRow(tree, i);
563            if (!parent.isDescendant(path)) return;
564
565            tree.expandPath(path);
566        }
567    }
568
569    void collapseNode(final int row, final boolean recursive) {
570        final TreePath path = getPathForRow(tree, row);
571        if (path == null) return;
572
573        if (recursive) {
574            collapseAllNodes(path, row + 1);
575        }
576
577        tree.collapsePath(path);
578    }
579
580    void collapseAllNodes(final TreePath parent, final int initialRow) {
581        int lastRow = -1;
582        for (int i = initialRow; lastRow == -1; i++) {
583            final TreePath path = getPathForRow(tree, i);
584            if (!parent.isDescendant(path)) {
585                lastRow = i - 1;
586            }
587        }
588
589        for (int i = lastRow; i >= initialRow; i--) {
590            final TreePath path = getPathForRow(tree, i);
591            tree.collapsePath(path);
592        }
593    }
594}
595