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:
2026-08-18 12:49:26 +00:00
parent 71084ea309
commit de02f88f1c
3 changed files with 323 additions and 0 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 &mdash; the tab strip itself
* and each custom tab label &mdash; 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);
}
}
}