Work tree rows by their whole line, and outline the hovered one
Swing hit-tests a tree row only as far as its label, so a click past the end of the name did nothing. Rows are now found by their vertical band alone, which covers selection, the context menus, double-click join and middle-click info; the expand handle to the left of a label still works the row without selecting it. The hovered row is outlined across that same full width, so what a click would act on is visible before making it. Spacers, which nothing can be done with, are skipped, and the outline gives way to the drag-and-drop indicator while a drag is in progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import com.ts3client.net.ServerModel;
|
|||||||
|
|
||||||
import javax.swing.Icon;
|
import javax.swing.Icon;
|
||||||
import javax.swing.JTree;
|
import javax.swing.JTree;
|
||||||
|
import javax.swing.SwingUtilities;
|
||||||
import javax.swing.tree.DefaultMutableTreeNode;
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
import javax.swing.tree.TreeModel;
|
import javax.swing.tree.TreeModel;
|
||||||
import javax.swing.tree.TreePath;
|
import javax.swing.tree.TreePath;
|
||||||
@@ -14,6 +15,9 @@ import java.awt.Graphics;
|
|||||||
import java.awt.Graphics2D;
|
import java.awt.Graphics2D;
|
||||||
import java.awt.Rectangle;
|
import java.awt.Rectangle;
|
||||||
import java.awt.RenderingHints;
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.event.MouseAdapter;
|
||||||
|
import java.awt.event.MouseEvent;
|
||||||
|
import java.awt.event.MouseWheelEvent;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,11 +38,69 @@ final class DropIndicatorTree extends JTree {
|
|||||||
private final GroupIcons groupIcons;
|
private final GroupIcons groupIcons;
|
||||||
/** Set while a drop would move a client into this channel row. */
|
/** Set while a drop would move a client into this channel row. */
|
||||||
private TreePath highlight;
|
private TreePath highlight;
|
||||||
|
/** The row the pointer is over, or -1 when it is over none. */
|
||||||
|
private int hoverRow = -1;
|
||||||
|
|
||||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
||||||
super(treeModel);
|
super(treeModel);
|
||||||
this.model = model;
|
this.model = model;
|
||||||
this.groupIcons = groupIcons;
|
this.groupIcons = groupIcons;
|
||||||
|
MouseAdapter hover = new MouseAdapter() {
|
||||||
|
@Override
|
||||||
|
public void mouseMoved(MouseEvent e) {
|
||||||
|
setHoverRow(rowAt(e.getY()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseDragged(MouseEvent e) {
|
||||||
|
setHoverRow(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseExited(MouseEvent e) {
|
||||||
|
setHoverRow(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void mouseWheelMoved(MouseWheelEvent e) {
|
||||||
|
// The pointer stays put while the rows move under it, so the row it is
|
||||||
|
// over has to be looked up again once the scroll has been applied.
|
||||||
|
SwingUtilities.invokeLater(() -> setHoverRow(rowAt(e.getY())));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
addMouseMotionListener(hover);
|
||||||
|
addMouseListener(hover);
|
||||||
|
addMouseWheelListener(hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row a point falls on, going by its vertical band alone: a row reaches
|
||||||
|
* across the whole width, not just as far as its label.
|
||||||
|
*/
|
||||||
|
int rowAt(int y) {
|
||||||
|
int row = getClosestRowForLocation(0, y);
|
||||||
|
if (row < 0) return -1;
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
return bounds != null && y >= bounds.y && y < bounds.y + bounds.height ? row : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The path a point falls on, by the same rule as {@link #rowAt(int)}. */
|
||||||
|
TreePath pathAt(int y) {
|
||||||
|
int row = rowAt(y);
|
||||||
|
return row < 0 ? null : getPathForRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setHoverRow(int row) {
|
||||||
|
if (row == hoverRow) return;
|
||||||
|
repaintRow(hoverRow);
|
||||||
|
hoverRow = row;
|
||||||
|
repaintRow(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void repaintRow(int row) {
|
||||||
|
if (row < 0) return;
|
||||||
|
Rectangle bounds = getRowBounds(row);
|
||||||
|
if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
/** Keeps the server row permanently open; collapsing it would hide everything. */
|
||||||
@@ -69,6 +131,7 @@ final class DropIndicatorTree extends JTree {
|
|||||||
protected void paintComponent(Graphics g) {
|
protected void paintComponent(Graphics g) {
|
||||||
super.paintComponent(g);
|
super.paintComponent(g);
|
||||||
paintBadges(g);
|
paintBadges(g);
|
||||||
|
paintHover(g);
|
||||||
JTree.DropLocation loc = getDropLocation();
|
JTree.DropLocation loc = getDropLocation();
|
||||||
if (loc == null || loc.getPath() == null) return;
|
if (loc == null || loc.getPath() == null) return;
|
||||||
|
|
||||||
@@ -91,6 +154,30 @@ final class DropIndicatorTree extends JTree {
|
|||||||
g2.dispose();
|
g2.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outlines the row under the pointer, across the full width the row occupies —
|
||||||
|
* the same area a click on it acts on. Spacers, which nothing can be done with,
|
||||||
|
* are left alone.
|
||||||
|
*/
|
||||||
|
private void paintHover(Graphics g) {
|
||||||
|
if (hoverRow < 0 || getDropLocation() != null) return;
|
||||||
|
Rectangle bounds = getRowBounds(hoverRow);
|
||||||
|
TreePath path = getPathForRow(hoverRow);
|
||||||
|
if (bounds == null || path == null || isSpacer(path)) return;
|
||||||
|
|
||||||
|
Rectangle visible = getVisibleRect();
|
||||||
|
Graphics2D g2 = (Graphics2D) g.create();
|
||||||
|
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||||
|
g2.setColor(Theme.TREE_HOVER);
|
||||||
|
g2.drawRoundRect(visible.x, bounds.y, visible.width - 1, bounds.height - 1, 4, 4);
|
||||||
|
g2.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isSpacer(TreePath path) {
|
||||||
|
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||||
|
return obj instanceof ChannelNode && Spacers.isSpacer(((ChannelNode) obj).name);
|
||||||
|
}
|
||||||
|
|
||||||
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
/** The 1px-tall strip where the insertion line goes, in tree coordinates. */
|
||||||
private Rectangle insertLine(JTree.DropLocation loc) {
|
private Rectangle insertLine(JTree.DropLocation loc) {
|
||||||
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
DefaultMutableTreeNode parent = (DefaultMutableTreeNode) loc.getPath().getLastPathComponent();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import javax.swing.plaf.basic.BasicTreeUI;
|
|||||||
import javax.swing.tree.DefaultMutableTreeNode;
|
import javax.swing.tree.DefaultMutableTreeNode;
|
||||||
import javax.swing.tree.DefaultTreeModel;
|
import javax.swing.tree.DefaultTreeModel;
|
||||||
import javax.swing.tree.TreePath;
|
import javax.swing.tree.TreePath;
|
||||||
|
import java.awt.Rectangle;
|
||||||
import java.awt.event.MouseAdapter;
|
import java.awt.event.MouseAdapter;
|
||||||
import java.awt.event.MouseEvent;
|
import java.awt.event.MouseEvent;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -159,6 +160,7 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
tree.addMouseListener(new MouseAdapter() {
|
tree.addMouseListener(new MouseAdapter() {
|
||||||
@Override
|
@Override
|
||||||
public void mousePressed(MouseEvent e) {
|
public void mousePressed(MouseEvent e) {
|
||||||
|
selectRowUnder(e);
|
||||||
maybePopup(e);
|
maybePopup(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,21 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects the row a press landed on. A row acts on its whole line, but Swing's own
|
||||||
|
* hit testing stops at the end of the label, so a press further right would leave
|
||||||
|
* the selection where it was.
|
||||||
|
*/
|
||||||
|
private void selectRowUnder(MouseEvent e) {
|
||||||
|
if (!SwingUtilities.isLeftMouseButton(e) && !e.isPopupTrigger()) return;
|
||||||
|
TreePath path = tree.pathAt(e.getY());
|
||||||
|
if (path == null || path.equals(tree.getSelectionPath())) return;
|
||||||
|
Rectangle bounds = tree.getPathBounds(path);
|
||||||
|
// Left of the label is the expand handle, which Swing works the row without selecting it.
|
||||||
|
if (bounds != null && e.getX() < bounds.x) return;
|
||||||
|
tree.setSelectionPath(path);
|
||||||
|
}
|
||||||
|
|
||||||
public void setSelfClientId(int id) {
|
public void setSelfClientId(int id) {
|
||||||
this.selfClientId = id;
|
this.selfClientId = id;
|
||||||
}
|
}
|
||||||
@@ -198,7 +215,7 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Object nodeAt(MouseEvent e) {
|
private Object nodeAt(MouseEvent e) {
|
||||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
TreePath path = tree.pathAt(e.getY());
|
||||||
if (path == null) return null;
|
if (path == null) return null;
|
||||||
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
|
DefaultMutableTreeNode n = (DefaultMutableTreeNode) path.getLastPathComponent();
|
||||||
return n.getUserObject();
|
return n.getUserObject();
|
||||||
@@ -206,7 +223,7 @@ public final class ServerTreePanel extends JScrollPane {
|
|||||||
|
|
||||||
private void maybePopup(MouseEvent e) {
|
private void maybePopup(MouseEvent e) {
|
||||||
if (!e.isPopupTrigger()) return;
|
if (!e.isPopupTrigger()) return;
|
||||||
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
|
TreePath path = tree.pathAt(e.getY());
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
tree.setSelectionPath(path);
|
tree.setSelectionPath(path);
|
||||||
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
Object obj = ((DefaultMutableTreeNode) path.getLastPathComponent()).getUserObject();
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ public final class Theme {
|
|||||||
public static final Color WINDOW_BG = new Color(0xF0F0F0);
|
public static final Color WINDOW_BG = new Color(0xF0F0F0);
|
||||||
public static final Color TREE_BG = new Color(0xFFFFFF);
|
public static final Color TREE_BG = new Color(0xFFFFFF);
|
||||||
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
|
public static final Color TREE_SELECTION = new Color(0xCFE3FB);
|
||||||
|
/** Outline drawn around the row the pointer is over. */
|
||||||
|
public static final Color TREE_HOVER = new Color(0x9CC4EE);
|
||||||
public static final Color TREE_TEXT = new Color(0x1E1E1E);
|
public static final Color TREE_TEXT = new Color(0x1E1E1E);
|
||||||
public static final Color CHANNEL_TEXT = new Color(0x21486B);
|
public static final Color CHANNEL_TEXT = new Color(0x21486B);
|
||||||
public static final Color SERVER_TEXT = new Color(0x123456);
|
public static final Color SERVER_TEXT = new Color(0x123456);
|
||||||
|
|||||||
Reference in New Issue
Block a user