Add browser-style drag-to-reorder for chat and server tabs
Dragging a tab shows animated snapshots on the glass pane and swaps with whichever neighbour it reaches the midpoint of; the real tab model is only touched once, on release, so an in-flight mouse grab never gets pulled out from under a rebuilt tab component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,8 @@ public final class ChatPanel extends JPanel {
|
||||
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
|
||||
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
|
||||
|
||||
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
|
||||
|
||||
private SendHandler sendHandler;
|
||||
private LinkHandler linkHandler;
|
||||
|
||||
@@ -83,6 +85,7 @@ public final class ChatPanel extends JPanel {
|
||||
if (t != null) t.setUnread(false);
|
||||
});
|
||||
tabs.addMouseWheelListener(this::onWheel);
|
||||
dragReorder.attach(tabs);
|
||||
add(tabs, BorderLayout.CENTER);
|
||||
|
||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||
@@ -235,6 +238,21 @@ public final class ChatPanel extends JPanel {
|
||||
tabs.remove(tab.scroll);
|
||||
}
|
||||
|
||||
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||
private void moveTab(int from, int to) {
|
||||
if (from < 0 || to < 0 || from >= tabs.getTabCount() || to >= tabs.getTabCount() || from == to) return;
|
||||
Component content = tabs.getComponentAt(from);
|
||||
String title = tabs.getTitleAt(from);
|
||||
Icon icon = tabs.getIconAt(from);
|
||||
String tip = tabs.getToolTipTextAt(from);
|
||||
Component header = tabs.getTabComponentAt(from);
|
||||
boolean wasSelected = tabs.getSelectedIndex() == from;
|
||||
tabs.removeTabAt(from);
|
||||
tabs.insertTab(title, icon, content, tip, to);
|
||||
tabs.setTabComponentAt(to, header);
|
||||
if (wasSelected) tabs.setSelectedIndex(to);
|
||||
}
|
||||
|
||||
private void onWheel(MouseWheelEvent e) {
|
||||
Component content = tabs.getSelectedComponent();
|
||||
if (content != null && content.getBounds().contains(e.getPoint())) return;
|
||||
@@ -339,6 +357,8 @@ public final class ChatPanel extends JPanel {
|
||||
titleLabel = new JLabel(title, icon, JLabel.LEADING);
|
||||
titleLabel.setFont(Theme.UI_FONT);
|
||||
p.add(titleLabel);
|
||||
dragReorder.attach(p);
|
||||
dragReorder.attach(titleLabel);
|
||||
if (closable) {
|
||||
JLabel close = new JLabel("×");
|
||||
close.setFont(Theme.UI_BOLD);
|
||||
|
||||
@@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel {
|
||||
private final Listener listener;
|
||||
private final JTabbedPane tabbed = new JTabbedPane();
|
||||
private final List<ServerTab> tabs = new ArrayList<>();
|
||||
private final TabDragReorder dragReorder = new TabDragReorder(tabbed, this::moveTab);
|
||||
|
||||
/** True while the tab strip is in use, i.e. more than one connection is open. */
|
||||
private boolean tabbedMode;
|
||||
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
||||
private boolean updating;
|
||||
/** Remembered so a drag-reorder can rebuild the tab labels without the caller's help. */
|
||||
private ServerTab lastMicTab;
|
||||
|
||||
ServerTabPane(Listener listener) {
|
||||
super(new BorderLayout());
|
||||
@@ -51,6 +54,24 @@ final class ServerTabPane extends JPanel {
|
||||
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
||||
});
|
||||
tabbed.addMouseWheelListener(this::onWheel);
|
||||
dragReorder.attach(tabbed);
|
||||
}
|
||||
|
||||
/** Drags a tab from one position to another, keeping the current selection on screen. */
|
||||
private void moveTab(int from, int to) {
|
||||
if (from < 0 || to < 0 || from >= tabs.size() || to >= tabs.size() || from == to) return;
|
||||
ServerTab selected = tabbed.getSelectedIndex() >= 0 && tabbed.getSelectedIndex() < tabs.size()
|
||||
? tabs.get(tabbed.getSelectedIndex()) : null;
|
||||
tabs.add(to, tabs.remove(from));
|
||||
updating = true;
|
||||
try {
|
||||
tabbed.removeAll();
|
||||
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
|
||||
if (selected != null) tabbed.setSelectedIndex(tabs.indexOf(selected));
|
||||
} finally {
|
||||
updating = false;
|
||||
}
|
||||
refresh(lastMicTab);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +114,7 @@ final class ServerTabPane extends JPanel {
|
||||
|
||||
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
|
||||
void refresh(ServerTab micTab) {
|
||||
lastMicTab = micTab;
|
||||
if (!tabbedMode) return;
|
||||
for (int i = 0; i < tabs.size(); i++) {
|
||||
ServerTab tab = tabs.get(i);
|
||||
@@ -143,6 +165,8 @@ final class ServerTabPane extends JPanel {
|
||||
}
|
||||
});
|
||||
cell.add(label);
|
||||
dragReorder.attach(cell);
|
||||
dragReorder.attach(label);
|
||||
|
||||
JButton close = new JButton("✕");
|
||||
close.setFont(Theme.UI_FONT);
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JRootPane;
|
||||
import javax.swing.JTabbedPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.Timer;
|
||||
import java.awt.AlphaComposite;
|
||||
import java.awt.Graphics;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Point;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lets the user reorder a {@link JTabbedPane}'s tabs by dragging one sideways over
|
||||
* another, browser-tab style. Snapshots of every tab are drawn on the window's
|
||||
* glass pane: the dragged one follows the cursor's X only (its Y stays put, and it
|
||||
* can't leave the strip) and stays drawn on top; it swaps with whichever neighbour
|
||||
* it reaches the midpoint of. The real tab model is only updated once, when the
|
||||
* mouse is released, and the overlay eases into its final position on top of it.
|
||||
*
|
||||
* <p>Swing delivers drag events only to whichever component originally got the
|
||||
* press, so every component that should start a drag — the tab strip itself
|
||||
* and each custom tab label — must be {@link #attach}ed individually.
|
||||
*/
|
||||
final class TabDragReorder {
|
||||
|
||||
/** Moves the tab at {@code from} to sit where {@code to} currently is. */
|
||||
interface Reorder {
|
||||
void moveTab(int from, int to);
|
||||
}
|
||||
|
||||
/** Below this many pixels of movement, a press is treated as a click, not a drag. */
|
||||
private static final int THRESHOLD = 5;
|
||||
private static final double EASE = 0.35;
|
||||
private static final double SETTLE_EPSILON = 0.5;
|
||||
private static final int FRAME_MS = 15;
|
||||
|
||||
private final JTabbedPane tabbed;
|
||||
private final Reorder reorder;
|
||||
|
||||
private int pressSlot = -1;
|
||||
private Point pressPoint;
|
||||
private boolean dragging;
|
||||
private boolean releasing;
|
||||
|
||||
private Overlay overlay;
|
||||
private List<Tile> order;
|
||||
private Tile draggedTile;
|
||||
private int dragSlot;
|
||||
private int startSlot;
|
||||
private int stripStartX;
|
||||
private int stripWidth;
|
||||
private int grabDx;
|
||||
private Timer timer;
|
||||
|
||||
TabDragReorder(JTabbedPane tabbed, Reorder reorder) {
|
||||
this.tabbed = tabbed;
|
||||
this.reorder = reorder;
|
||||
}
|
||||
|
||||
void attach(JComponent source) {
|
||||
MouseAdapter listener = new MouseAdapter() {
|
||||
@Override
|
||||
public void mousePressed(MouseEvent e) {
|
||||
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
|
||||
pressSlot = tabbed.indexAtLocation(inTabbed.x, inTabbed.y);
|
||||
pressPoint = inTabbed;
|
||||
dragging = false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseDragged(MouseEvent e) {
|
||||
if (pressSlot < 0) return;
|
||||
Point inTabbed = SwingUtilities.convertPoint(e.getComponent(), e.getPoint(), tabbed);
|
||||
if (!dragging) {
|
||||
if (pressPoint.distance(inTabbed) < THRESHOLD) return;
|
||||
dragging = beginDrag(inTabbed);
|
||||
if (!dragging) return;
|
||||
}
|
||||
dragTo(inTabbed.x);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased(MouseEvent e) {
|
||||
if (dragging) endDrag();
|
||||
pressSlot = -1;
|
||||
dragging = false;
|
||||
}
|
||||
};
|
||||
source.addMouseListener(listener);
|
||||
source.addMouseMotionListener(listener);
|
||||
}
|
||||
|
||||
/** Snapshots every tab and shows them on the glass pane in place of the real strip. */
|
||||
private boolean beginDrag(Point inTabbed) {
|
||||
JRootPane root = tabbed.getRootPane();
|
||||
int n = tabbed.getTabCount();
|
||||
if (root == null || pressSlot < 0 || pressSlot >= n) return false;
|
||||
|
||||
order = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
Rectangle b = tabbed.getBoundsAt(i);
|
||||
if (b == null || b.isEmpty()) return false;
|
||||
order.add(new Tile(snapshot(b), b.x, b.y, b.width, b.height));
|
||||
}
|
||||
stripStartX = order.get(0).x;
|
||||
stripWidth = 0;
|
||||
for (Tile t : order) stripWidth += t.width;
|
||||
draggedTile = order.get(pressSlot);
|
||||
dragSlot = pressSlot;
|
||||
startSlot = pressSlot;
|
||||
grabDx = inTabbed.x - draggedTile.x;
|
||||
releasing = false;
|
||||
|
||||
if (!(root.getGlassPane() instanceof Overlay)) {
|
||||
root.setGlassPane(new Overlay());
|
||||
}
|
||||
overlay = (Overlay) root.getGlassPane();
|
||||
overlay.origin = SwingUtilities.convertPoint(tabbed, new Point(0, 0), overlay);
|
||||
overlay.tiles = order;
|
||||
overlay.onTop = draggedTile;
|
||||
overlay.setVisible(true);
|
||||
overlay.repaint();
|
||||
|
||||
timer = new Timer(FRAME_MS, e -> tick());
|
||||
timer.start();
|
||||
return true;
|
||||
}
|
||||
|
||||
private BufferedImage snapshot(Rectangle bounds) {
|
||||
BufferedImage img = new BufferedImage(bounds.width, bounds.height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D g = img.createGraphics();
|
||||
g.setClip(0, 0, bounds.width, bounds.height);
|
||||
g.translate(-bounds.x, -bounds.y);
|
||||
tabbed.paint(g);
|
||||
g.dispose();
|
||||
return img;
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps the dragged tile with whichever neighbour it has reached the midpoint
|
||||
* of: its trailing edge past the next tile's midpoint when moving right, or its
|
||||
* leading edge past the previous tile's midpoint when moving left. Checked
|
||||
* against the neighbour's target slot rather than its live position, since that
|
||||
* may itself still be mid-animation.
|
||||
*/
|
||||
private void dragTo(int cursorX) {
|
||||
int min = stripStartX;
|
||||
int max = stripStartX + stripWidth - draggedTile.width;
|
||||
draggedTile.currentX = Math.max(min, Math.min(max, cursorX - grabDx));
|
||||
|
||||
if (dragSlot + 1 < order.size()) {
|
||||
Tile next = order.get(dragSlot + 1);
|
||||
double rightEdge = draggedTile.currentX + draggedTile.width;
|
||||
if (rightEdge > next.targetX + next.width / 2.0) {
|
||||
swap(dragSlot, dragSlot + 1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dragSlot - 1 >= 0) {
|
||||
Tile prev = order.get(dragSlot - 1);
|
||||
double leftEdge = draggedTile.currentX;
|
||||
if (leftEdge < prev.targetX + prev.width / 2.0) {
|
||||
swap(dragSlot - 1, dragSlot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps the two adjacent slots {@code a} and {@code b} (one of them the dragged tile's) in {@link #order}, not the real tab model. */
|
||||
private void swap(int a, int b) {
|
||||
int newSlot = dragSlot == a ? b : a;
|
||||
order.remove(draggedTile);
|
||||
order.add(newSlot, draggedTile);
|
||||
dragSlot = newSlot;
|
||||
retarget(false);
|
||||
}
|
||||
|
||||
/** Assigns each tile's slot target from the current {@code order}; the dragged tile follows the cursor instead, unless {@code includeDragged}. */
|
||||
private void retarget(boolean includeDragged) {
|
||||
double x = stripStartX;
|
||||
for (Tile t : order) {
|
||||
if (t != draggedTile || includeDragged) t.targetX = x;
|
||||
x += t.width;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits the reorder to the real tab model in one shot. Doing this once here,
|
||||
* rather than per swap while dragging, matters because reordering the real
|
||||
* model can rebuild tab components (new labels, new listeners), which would
|
||||
* otherwise yank the component out from under an in-flight mouse grab.
|
||||
*/
|
||||
private void endDrag() {
|
||||
releasing = true;
|
||||
if (dragSlot != startSlot) reorder.moveTab(startSlot, dragSlot);
|
||||
retarget(true);
|
||||
}
|
||||
|
||||
private void tick() {
|
||||
boolean settled = true;
|
||||
for (Tile t : order) {
|
||||
if (t == draggedTile && !releasing) continue;
|
||||
double diff = t.targetX - t.currentX;
|
||||
if (Math.abs(diff) < SETTLE_EPSILON) {
|
||||
t.currentX = t.targetX;
|
||||
} else {
|
||||
t.currentX += diff * EASE;
|
||||
settled = false;
|
||||
}
|
||||
}
|
||||
overlay.repaint();
|
||||
if (releasing && settled) {
|
||||
timer.stop();
|
||||
overlay.tiles = null;
|
||||
overlay.onTop = null;
|
||||
overlay.setVisible(false);
|
||||
order = null;
|
||||
draggedTile = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** One tab's snapshot, animating from {@link #currentX} toward {@link #targetX}. */
|
||||
private static final class Tile {
|
||||
final BufferedImage image;
|
||||
final int x, y, width, height;
|
||||
double currentX;
|
||||
double targetX;
|
||||
|
||||
Tile(BufferedImage image, int x, int y, int width, int height) {
|
||||
this.image = image;
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.currentX = x;
|
||||
this.targetX = x;
|
||||
}
|
||||
}
|
||||
|
||||
/** A transparent overlay on the glass pane that paints every tab's animated snapshot. */
|
||||
private static final class Overlay extends JComponent {
|
||||
List<Tile> tiles;
|
||||
/** Painted last (on top) so it stays above tabs it's currently overlapping while dragged. */
|
||||
Tile onTop;
|
||||
Point origin = new Point();
|
||||
|
||||
Overlay() {
|
||||
setOpaque(false);
|
||||
}
|
||||
|
||||
/** Never claims mouse events, so drags keep reaching the component that was actually pressed. */
|
||||
@Override
|
||||
public boolean contains(int x, int y) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void paintComponent(Graphics g) {
|
||||
if (tiles == null) return;
|
||||
Graphics2D g2 = (Graphics2D) g.create();
|
||||
g2.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.9f));
|
||||
for (Tile t : tiles) {
|
||||
if (t != onTop) draw(g2, t);
|
||||
}
|
||||
if (onTop != null) draw(g2, onTop);
|
||||
g2.dispose();
|
||||
}
|
||||
|
||||
private void draw(Graphics2D g2, Tile t) {
|
||||
g2.drawImage(t.image, origin.x + (int) Math.round(t.currentX), origin.y + t.y, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user