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. */
|
/** Static-content tabs (e.g. a moved-out description), keyed by caller-chosen id. */
|
||||||
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
|
private final Map<String, Tab> noteTabs = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
private final TabDragReorder dragReorder = new TabDragReorder(tabs, this::moveTab);
|
||||||
|
|
||||||
private SendHandler sendHandler;
|
private SendHandler sendHandler;
|
||||||
private LinkHandler linkHandler;
|
private LinkHandler linkHandler;
|
||||||
|
|
||||||
@@ -83,6 +85,7 @@ public final class ChatPanel extends JPanel {
|
|||||||
if (t != null) t.setUnread(false);
|
if (t != null) t.setUnread(false);
|
||||||
});
|
});
|
||||||
tabs.addMouseWheelListener(this::onWheel);
|
tabs.addMouseWheelListener(this::onWheel);
|
||||||
|
dragReorder.attach(tabs);
|
||||||
add(tabs, BorderLayout.CENTER);
|
add(tabs, BorderLayout.CENTER);
|
||||||
|
|
||||||
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
JPanel bottom = new JPanel(new BorderLayout(4, 0));
|
||||||
@@ -235,6 +238,21 @@ public final class ChatPanel extends JPanel {
|
|||||||
tabs.remove(tab.scroll);
|
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) {
|
private void onWheel(MouseWheelEvent e) {
|
||||||
Component content = tabs.getSelectedComponent();
|
Component content = tabs.getSelectedComponent();
|
||||||
if (content != null && content.getBounds().contains(e.getPoint())) return;
|
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 = new JLabel(title, icon, JLabel.LEADING);
|
||||||
titleLabel.setFont(Theme.UI_FONT);
|
titleLabel.setFont(Theme.UI_FONT);
|
||||||
p.add(titleLabel);
|
p.add(titleLabel);
|
||||||
|
dragReorder.attach(p);
|
||||||
|
dragReorder.attach(titleLabel);
|
||||||
if (closable) {
|
if (closable) {
|
||||||
JLabel close = new JLabel("×");
|
JLabel close = new JLabel("×");
|
||||||
close.setFont(Theme.UI_BOLD);
|
close.setFont(Theme.UI_BOLD);
|
||||||
|
|||||||
@@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel {
|
|||||||
private final Listener listener;
|
private final Listener listener;
|
||||||
private final JTabbedPane tabbed = new JTabbedPane();
|
private final JTabbedPane tabbed = new JTabbedPane();
|
||||||
private final List<ServerTab> tabs = new ArrayList<>();
|
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. */
|
/** True while the tab strip is in use, i.e. more than one connection is open. */
|
||||||
private boolean tabbedMode;
|
private boolean tabbedMode;
|
||||||
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
/** Suppresses selection callbacks while we rearrange the pane ourselves. */
|
||||||
private boolean updating;
|
private boolean updating;
|
||||||
|
/** Remembered so a drag-reorder can rebuild the tab labels without the caller's help. */
|
||||||
|
private ServerTab lastMicTab;
|
||||||
|
|
||||||
ServerTabPane(Listener listener) {
|
ServerTabPane(Listener listener) {
|
||||||
super(new BorderLayout());
|
super(new BorderLayout());
|
||||||
@@ -51,6 +54,24 @@ final class ServerTabPane extends JPanel {
|
|||||||
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
|
||||||
});
|
});
|
||||||
tabbed.addMouseWheelListener(this::onWheel);
|
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. */
|
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
|
||||||
void refresh(ServerTab micTab) {
|
void refresh(ServerTab micTab) {
|
||||||
|
lastMicTab = micTab;
|
||||||
if (!tabbedMode) return;
|
if (!tabbedMode) return;
|
||||||
for (int i = 0; i < tabs.size(); i++) {
|
for (int i = 0; i < tabs.size(); i++) {
|
||||||
ServerTab tab = tabs.get(i);
|
ServerTab tab = tabs.get(i);
|
||||||
@@ -143,6 +165,8 @@ final class ServerTabPane extends JPanel {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cell.add(label);
|
cell.add(label);
|
||||||
|
dragReorder.attach(cell);
|
||||||
|
dragReorder.attach(label);
|
||||||
|
|
||||||
JButton close = new JButton("✕");
|
JButton close = new JButton("✕");
|
||||||
close.setFont(Theme.UI_FONT);
|
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