From de02f88f1c5f0f1a49d8a38b96382a038bdd1008 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Tue, 18 Aug 2026 12:49:26 +0000 Subject: [PATCH] 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 --- .../main/java/com/ts3client/ui/ChatPanel.java | 20 ++ .../java/com/ts3client/ui/ServerTabPane.java | 24 ++ .../java/com/ts3client/ui/TabDragReorder.java | 279 ++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/TabDragReorder.java diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java index 8cf5006..a70ba09 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ChatPanel.java @@ -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 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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java index e627e9f..84a46dc 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java @@ -34,11 +34,14 @@ final class ServerTabPane extends JPanel { private final Listener listener; private final JTabbedPane tabbed = new JTabbedPane(); private final List 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); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TabDragReorder.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TabDragReorder.java new file mode 100644 index 0000000..3fc2fdc --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TabDragReorder.java @@ -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. + * + *

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 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 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); + } + } +}