Use a real tab strip for servers instead of a button bar

ServerTabPane replaces ServerTabBar: the views live in a JTabbedPane
whose strip sits under the toolbar, with the native tab shapes and the
same look as the chat tabs. Each tab carries its server name, the
microphone marker and a close glyph. With a single connection the
server view is mounted directly, so the strip appears only once a
second server is open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 15:23:25 +00:00
parent 03c6ea9a3b
commit 41bf9506e3
3 changed files with 157 additions and 104 deletions

View File

@@ -9,7 +9,6 @@ import com.ts3client.config.Settings;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JCheckBoxMenuItem;
@@ -26,7 +25,6 @@ import javax.swing.JToolBar;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import java.awt.BorderLayout;
import java.awt.CardLayout;
import java.awt.Dimension;
import java.awt.GridLayout;
import java.awt.KeyEventDispatcher;
@@ -44,9 +42,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
*
* <p>Several servers can be connected at once — their incoming voice is mixed
* into the same output — but only one of them owns the microphone at a time
* ("active" tab, marked in the tab bar and switched from the toolbar).
* ("active" tab, marked in the tab strip and switched from the toolbar).
*/
public final class MainFrame extends JFrame implements ServerTabBar.Listener {
public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load();
@@ -54,10 +52,7 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
private final AudioBackend audio = new JavaSoundAudioBackend();
private final List<ServerTab> tabs = new ArrayList<>();
private final CardLayout cards = new CardLayout();
private final JPanel cardPanel = new JPanel(cards);
private final ServerTabBar tabBar = new ServerTabBar(this);
private int tabCounter;
private final ServerTabPane tabPane = new ServerTabPane(this);
/** The tab whose views are on screen. */
private ServerTab selected;
@@ -105,12 +100,8 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
setJMenuBar(buildMenuBar());
JPanel top = new JPanel();
top.setLayout(new BoxLayout(top, BoxLayout.Y_AXIS));
top.add(buildToolbar());
top.add(tabBar);
add(top, BorderLayout.NORTH);
add(cardPanel, BorderLayout.CENTER);
add(buildToolbar(), BorderLayout.NORTH);
add(tabPane, BorderLayout.CENTER);
add(buildStatusBar(), BorderLayout.SOUTH);
codecLabel.setText(audio.description());
@@ -292,11 +283,9 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
private ServerTab newTab() {
ServerTab tab = new ServerTab(this, settings, identities, audio);
String name = "tab" + (tabCounter++);
tab.component().setName(name); // CardLayout addresses cards by this name
tabs.add(tab);
cardPanel.add(tab.component(), name);
refreshTabBar();
tabPane.addTab(tab);
refreshTabs();
return tab;
}
@@ -312,8 +301,8 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
public void selectTab(ServerTab tab) {
if (tab == null || !tabs.contains(tab)) return;
selected = tab;
cards.show(cardPanel, tab.component().getName());
refreshTabBar();
tabPane.setSelected(tab);
refreshTabs();
updateToolbar();
updateStatusLabel();
}
@@ -325,13 +314,13 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
if (tabs.size() == 1) return; // always keep one view around
tabs.remove(tab);
cardPanel.remove(tab.component());
tabPane.removeTab(tab);
if (micTab == tab) micTab = null;
if (selected == tab) {
selected = null;
selectTab(tabs.get(0));
} else {
refreshTabBar();
selectTab(selected); // the pane rebuilt itself; restore the selection
}
assignMicrophoneIfFree();
}
@@ -341,7 +330,7 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
if (micTab == tab) return;
ServerTab previous = micTab;
micTab = tab;
refreshTabBar();
refreshTabs();
// Closing and reopening the capture line can block briefly; keep it off the EDT.
new Thread(() -> {
if (previous != null) previous.setMicrophoneActive(false);
@@ -359,13 +348,12 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
return;
}
}
refreshTabBar();
refreshTabs();
}
private void refreshTabBar() {
tabBar.rebuild(tabs, selected, micTab);
// The bar appears/disappears with the second connection.
getContentPane().revalidate();
/** Repaints the tab labels (title, microphone marker). */
private void refreshTabs() {
tabPane.refresh(micTab);
}
// ---- callbacks from ServerTab ----
@@ -373,7 +361,7 @@ public final class MainFrame extends JFrame implements ServerTabBar.Listener {
/** A tab's title, status or connection state changed. */
void tabUpdated(ServerTab tab) {
if (!tabs.contains(tab)) return;
refreshTabBar();
refreshTabs();
if (tab == selected) {
updateToolbar();
updateStatusLabel();

View File

@@ -1,75 +0,0 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.JToggleButton;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Insets;
import java.util.List;
/**
* Strip of open server connections, shown between the toolbar and the server
* view. The bar only appears while more than one connection is open; with a
* single server the client looks exactly as it did before.
*/
final class ServerTabBar extends JPanel {
interface Listener {
void selectTab(ServerTab tab);
void closeTab(ServerTab tab);
}
private final Listener listener;
ServerTabBar(Listener listener) {
super(new FlowLayout(FlowLayout.LEFT, 3, 2));
this.listener = listener;
setBackground(Theme.TOOLBAR_BG);
setBorder(BorderFactory.createMatteBorder(0, 0, 1, 0, Theme.WINDOW_BG));
setVisible(false);
}
/**
* @param micTab the connection holding the microphone, marked with an icon
*/
void rebuild(List<ServerTab> tabs, ServerTab selected, ServerTab micTab) {
removeAll();
for (ServerTab tab : tabs) {
add(buildCell(tab, tab == selected, tab == micTab, tabs.size() > 1));
}
setVisible(tabs.size() > 1);
revalidate();
repaint();
}
private JPanel buildCell(ServerTab tab, boolean selected, boolean hasMic, boolean closable) {
JPanel cell = new JPanel(new FlowLayout(FlowLayout.LEFT, 0, 0));
cell.setOpaque(false);
JToggleButton button = new JToggleButton(tab.title(), hasMic ? Icons.micActive() : null);
button.setSelected(selected);
button.setFocusable(false);
button.setFont(selected ? Theme.UI_BOLD : Theme.UI_FONT);
button.setMargin(new Insets(2, 8, 2, 8));
button.setToolTipText(tab.status());
button.addActionListener(e -> listener.selectTab(tab));
cell.add(button);
if (closable) {
JButton close = new JButton("");
close.setFocusable(false);
close.setFont(Theme.UI_FONT);
close.setMargin(new Insets(2, 5, 2, 5));
// Keep the cell compact but never so small that the glyph is clipped.
Dimension size = close.getPreferredSize();
close.setPreferredSize(new Dimension(size.width, button.getPreferredSize().height));
close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab));
cell.add(close);
}
return cell;
}
}

View File

@@ -0,0 +1,140 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JTabbedPane;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.awt.Insets;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
/**
* Container for the open server connections. With a single connection the
* server view fills the window as it always did; from the second connection on
* the views move into a {@link JTabbedPane} whose tab strip sits directly under
* the toolbar.
*/
final class ServerTabPane extends JPanel {
interface Listener {
void selectTab(ServerTab tab);
void closeTab(ServerTab tab);
}
private final Listener listener;
private final JTabbedPane tabbed = new JTabbedPane();
private final List<ServerTab> tabs = new ArrayList<>();
/** 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;
ServerTabPane(Listener listener) {
super(new BorderLayout());
this.listener = listener;
tabbed.setTabLayoutPolicy(JTabbedPane.SCROLL_TAB_LAYOUT);
tabbed.setFocusable(false);
tabbed.addChangeListener(e -> {
if (updating) return;
int i = tabbed.getSelectedIndex();
if (i >= 0 && i < tabs.size()) listener.selectTab(tabs.get(i));
});
}
void addTab(ServerTab tab) {
tabs.add(tab);
relayout();
}
void removeTab(ServerTab tab) {
tabs.remove(tab);
relayout();
}
void setSelected(ServerTab tab) {
int i = tabs.indexOf(tab);
if (!tabbedMode || i < 0 || tabbed.getSelectedIndex() == i) return;
updating = true;
try {
tabbed.setSelectedIndex(i);
} finally {
updating = false;
}
}
/** Refreshes the tab labels; {@code micTab} is marked as owning the microphone. */
void refresh(ServerTab micTab) {
if (!tabbedMode) return;
for (int i = 0; i < tabs.size(); i++) {
ServerTab tab = tabs.get(i);
tabbed.setTitleAt(i, tab.title());
tabbed.setToolTipTextAt(i, tab.status());
tabbed.setTabComponentAt(i, buildTabComponent(tab, tab == micTab));
}
}
/**
* Moves the views between the plain single-server layout and the tabbed one,
* keeping whatever is currently selected on screen.
*/
private void relayout() {
boolean wantTabs = tabs.size() > 1;
updating = true;
try {
removeAll();
tabbed.removeAll();
if (wantTabs) {
for (ServerTab tab : tabs) tabbed.addTab(tab.title(), tab.component());
add(tabbed, BorderLayout.CENTER);
} else if (!tabs.isEmpty()) {
add(tabs.get(0).component(), BorderLayout.CENTER);
}
tabbedMode = wantTabs;
} finally {
updating = false;
}
revalidate();
repaint();
}
/** A tab's label: title, microphone marker and a close button. */
private JComponent buildTabComponent(ServerTab tab, boolean hasMic) {
JPanel cell = new JPanel(new FlowLayout(FlowLayout.LEFT, 4, 0));
cell.setOpaque(false);
cell.setBorder(BorderFactory.createEmptyBorder(1, 0, 1, 0));
JLabel label = new JLabel(tab.title(), hasMic ? Icons.micActive() : null, JLabel.LEADING);
label.setFont(Theme.UI_FONT);
label.setToolTipText(hasMic ? "Speaking on this server" : tab.status());
// A label with a tooltip swallows mouse events, so select the tab explicitly.
label.addMouseListener(new MouseAdapter() {
@Override
public void mousePressed(MouseEvent e) {
listener.selectTab(tab);
}
});
cell.add(label);
JButton close = new JButton("");
close.setFont(Theme.UI_FONT);
close.setFocusable(false);
close.setBorder(BorderFactory.createEmptyBorder());
close.setContentAreaFilled(false);
close.setMargin(new Insets(0, 0, 0, 0));
close.setForeground(Theme.CHAT_SYSTEM);
close.setPreferredSize(new Dimension(14, 14));
close.setToolTipText("Close this connection");
close.addActionListener(e -> listener.closeTab(tab));
cell.add(close);
return cell;
}
}