From 41bf9506e3c69919ff1a1cdcecd8eec7cbe0f66d Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 13 Aug 2026 15:23:25 +0000 Subject: [PATCH] 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 --- .../main/java/com/ts3client/ui/MainFrame.java | 46 +++--- .../java/com/ts3client/ui/ServerTabBar.java | 75 ---------- .../java/com/ts3client/ui/ServerTabPane.java | 140 ++++++++++++++++++ 3 files changed, 157 insertions(+), 104 deletions(-) delete mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java create mode 100644 ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 026f2b2..d9be3e7 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -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; * *

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 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(); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java deleted file mode 100644 index d22a2be..0000000 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabBar.java +++ /dev/null @@ -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 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; - } -} 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 new file mode 100644 index 0000000..9567c71 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabPane.java @@ -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 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; + } +}