Add an away button with global actions and message presets

The toolbar gains an away toggle with a drop-down: it toggles away on the
current server with no message, while the menu carries the global actions
("Set Globally Away", "Set Globally Away Status"), the saved presets and
their editor. Presets live in ~/.ts3jclient/away.properties and are
managed in a list with add/remove and double-click renaming.

Clients in the channel tree now show their away message in brackets after
the nickname instead of their primary server group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 13:40:19 +00:00
parent 25a78f52fe
commit 245ae5ecc5
9 changed files with 491 additions and 18 deletions

View File

@@ -0,0 +1,147 @@
package com.ts3client.ui;
import com.ts3client.config.AwayMessages;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTable;
import javax.swing.ListSelectionModel;
import javax.swing.table.AbstractTableModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
/**
* Editor for the away-message presets: a plain list of entries that are renamed
* by double-clicking them, plus Add and Remove.
*
* <p>Backed by a one-column table because that is what gives Swing's list look
* inline editing for free. Every change is written straight through to the
* {@link AwayMessages} store.
*/
public final class AwayMessagesDialog extends JDialog {
private final AwayMessages messages;
private final Runnable onChanged;
private final Model model = new Model();
private final JTable table = new JTable(model);
public AwayMessagesDialog(Frame owner, AwayMessages messages, Runnable onChanged) {
super(owner, "Away Message Presets", true);
this.messages = messages;
this.onChanged = onChanged;
table.setTableHeader(null);
table.setShowGrid(false);
table.setFillsViewportHeight(true);
table.setRowHeight(Math.max(20, table.getRowHeight()));
table.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
// Single click selects, double click starts editing — like a renameable list.
table.putClientProperty("JTable.autoStartsEdit", Boolean.FALSE);
JScrollPane scroll = new JScrollPane(table);
scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8));
getContentPane().setLayout(new BorderLayout());
getContentPane().add(scroll, BorderLayout.CENTER);
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
setSize(new Dimension(360, 300));
setLocationRelativeTo(owner);
}
private JPanel buildButtons() {
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
buttons.setBorder(BorderFactory.createEmptyBorder(0, 8, 8, 8));
addButton(buttons, "Add", this::addEntry);
addButton(buttons, "Remove", this::removeSelected);
buttons.add(Box.createHorizontalGlue());
addButton(buttons, "Close", this::closeDialog);
return buttons;
}
private void addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createHorizontalStrut(4));
}
private void addEntry() {
stopEditing();
messages.add("New away message");
int row = messages.all().size() - 1;
model.fireTableRowsInserted(row, row);
table.setRowSelectionInterval(row, row);
table.scrollRectToVisible(table.getCellRect(row, 0, true));
persist();
table.editCellAt(row, 0);
if (table.getEditorComponent() != null) table.getEditorComponent().requestFocusInWindow();
}
private void removeSelected() {
stopEditing();
int row = table.getSelectedRow();
if (row < 0) return;
messages.remove(row);
model.fireTableRowsDeleted(row, row);
if (!messages.all().isEmpty()) {
int next = Math.min(row, messages.all().size() - 1);
table.setRowSelectionInterval(next, next);
}
persist();
}
private void closeDialog() {
stopEditing();
dispose();
}
/** Flushes a cell that is still being edited so its text is not lost. */
private void stopEditing() {
if (table.isEditing()) table.getCellEditor().stopCellEditing();
}
private void persist() {
messages.save();
if (onChanged != null) onChanged.run();
}
private final class Model extends AbstractTableModel {
@Override
public int getRowCount() {
return messages.all().size();
}
@Override
public int getColumnCount() {
return 1;
}
@Override
public Object getValueAt(int row, int column) {
return messages.all().get(row);
}
@Override
public boolean isCellEditable(int row, int column) {
return true;
}
@Override
public void setValueAt(Object value, int row, int column) {
String text = value == null ? "" : value.toString().trim();
if (text.isEmpty()) return; // an emptied entry keeps its old text
messages.set(row, text);
persist();
}
}
}

View File

@@ -0,0 +1,115 @@
package com.ts3client.ui;
import javax.swing.BorderFactory;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JToggleButton;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Insets;
import java.awt.RenderingHints;
import java.awt.event.ActionListener;
import java.util.function.Supplier;
/**
* A toolbar toggle button with a small arrow next to it that opens a menu:
* clicking the button toggles, clicking the arrow offers the related actions.
*
* <p>The menu is built on every click by the supplier, so it can reflect the
* current state (checked items, the presets that exist right now).
*/
public final class DropDownToggleButton extends JPanel {
private final JToggleButton button = new JToggleButton();
private final JButton arrow = new JButton(new ArrowIcon());
public DropDownToggleButton(Icon icon, String tooltip, Supplier<JPopupMenu> menu) {
super(new BorderLayout());
setOpaque(false);
button.setIcon(icon);
button.setToolTipText(tooltip);
arrow.setToolTipText(tooltip);
arrow.setMargin(new Insets(0, 2, 0, 2));
arrow.setFocusable(false);
arrow.addActionListener(e -> {
JPopupMenu popup = menu.get();
if (popup != null) popup.show(this, 0, getHeight());
});
// Keep the whole control the size of a plain toolbar button: the arrow's
// width comes out of the toggle half instead of being added to it.
Dimension plain = button.getPreferredSize();
button.setPreferredSize(new Dimension(
Math.max(icon.getIconWidth() + 8, plain.width - arrow.getPreferredSize().width),
plain.height));
add(button, BorderLayout.CENTER);
add(arrow, BorderLayout.EAST);
}
/** Listener for the toggle half only; the arrow half opens the menu instead. */
public void addActionListener(ActionListener l) {
button.addActionListener(l);
}
public boolean isSelected() {
return button.isSelected();
}
public void setSelected(boolean selected) {
button.setSelected(selected);
}
public void setIcon(Icon icon) {
button.setIcon(icon);
}
@Override
public void setEnabled(boolean enabled) {
super.setEnabled(enabled);
button.setEnabled(enabled);
arrow.setEnabled(enabled);
}
/** Disables the toggle half on its own, leaving the menu reachable. */
public void setToggleEnabled(boolean enabled) {
button.setEnabled(enabled);
}
@Override
public Dimension getMaximumSize() {
return getPreferredSize(); // keep the toolbar from stretching us
}
/** The downward triangle on the menu half of the button. */
private static final class ArrowIcon implements Icon {
@Override
public void paintIcon(Component c, Graphics g0, int x, int y) {
Graphics2D g = (Graphics2D) g0.create();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setColor(c.isEnabled() ? Color.DARK_GRAY : Color.GRAY);
int[] xs = {x, x + getIconWidth(), x + getIconWidth() / 2};
int[] ys = {y, y, y + getIconHeight()};
g.fillPolygon(xs, ys, 3);
g.dispose();
}
@Override
public int getIconWidth() {
return 7;
}
@Override
public int getIconHeight() {
return 4;
}
}
}

View File

@@ -148,6 +148,11 @@ public final class Icons {
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
}
/** The toolbar's away marker. */
public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.AWAY));
}
public static ImageIcon settings() {
return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
}

View File

@@ -2,6 +2,7 @@ package com.ts3client.ui;
import com.ts3client.audio.AudioBackend;
import com.ts3client.audio.desktop.DesktopAudioBackend;
import com.ts3client.config.AwayMessages;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
@@ -21,6 +22,7 @@ import javax.swing.JMenuBar;
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JTextField;
import javax.swing.JToggleButton;
import javax.swing.JToolBar;
@@ -50,6 +52,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private final Settings settings;
private final Bookmarks bookmarks = Bookmarks.load();
private final AwayMessages awayMessages = AwayMessages.load();
private final IdentityStore identities;
private final AudioBackend audio = new DesktopAudioBackend();
/** Sound pack playback, shared by every connection. */
@@ -66,6 +69,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private JMenu bookmarksMenu;
private JCheckBoxMenuItem awayItem;
private JMenuItem awayStatusItem;
private JCheckBoxMenuItem commanderItem;
private javax.swing.Timer statusTimer;
@@ -78,6 +82,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
private JToggleButton activeButton;
private JToggleButton micButton;
private JToggleButton speakerButton;
private DropDownToggleButton awayButton;
private boolean pttPressed;
@@ -173,6 +178,8 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
deaf.addActionListener(e -> speakerButton.doClick());
awayItem = new JCheckBoxMenuItem("Away", Icons.of("AWAY"), false);
awayItem.addActionListener(e -> toggleAway());
awayStatusItem = new JMenuItem("Set away status…", Icons.of("EDIT"));
awayStatusItem.addActionListener(e -> setAwayStatus());
commanderItem = new JCheckBoxMenuItem("Channel commander", Icons.of("CHANNEL_COMMANDER"), false);
commanderItem.addActionListener(e -> {
if (selected != null) selected.setCommander(commanderItem.isSelected());
@@ -183,6 +190,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
self.add(deaf);
self.addSeparator();
self.add(awayItem);
self.add(awayStatusItem);
self.add(commanderItem);
self.addSeparator();
self.add(nick);
@@ -263,6 +271,16 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
updateToolbar();
});
awayButton = new DropDownToggleButton(Icons.away(),
"Away on this server (the arrow offers the global actions and presets)",
this::buildAwayMenu);
awayButton.addActionListener(e -> {
if (selected == null) return;
// The plain toggle carries no message; the menu is where messages are chosen.
selected.setAway(awayButton.isSelected(), "");
updateToolbar();
});
JButton settingsButton = new JButton(Icons.settings());
settingsButton.setToolTipText("Options");
settingsButton.addActionListener(e -> showSettings());
@@ -273,6 +291,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
tb.add(activeButton);
tb.add(micButton);
tb.add(speakerButton);
tb.add(awayButton);
tb.addSeparator();
tb.add(settingsButton);
tb.add(Box.createHorizontalGlue());
@@ -495,18 +514,86 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
rebuildBookmarksMenu();
}
/** The away button's drop-down: the global actions, the presets and their editor. */
private JPopupMenu buildAwayMenu() {
JPopupMenu menu = new JPopupMenu();
boolean anyConnected = tabs.stream().anyMatch(ServerTab::isConnected);
JCheckBoxMenuItem globally = new JCheckBoxMenuItem("Set Globally Away", Icons.of("AWAY"), isGloballyAway());
globally.setEnabled(anyConnected);
globally.addActionListener(e -> setAwayEverywhere(globally.isSelected(), null));
JMenuItem globalStatus = new JMenuItem("Set Globally Away Status…", Icons.of("EDIT"));
globalStatus.setEnabled(anyConnected);
globalStatus.addActionListener(e -> {
String message = askAwayMessage(currentAwayMessage());
if (message != null) setAwayEverywhere(true, message);
});
menu.add(globally);
menu.add(globalStatus);
menu.addSeparator();
for (String preset : awayMessages.all()) {
JMenuItem item = new JMenuItem(preset);
item.setEnabled(anyConnected);
item.addActionListener(e -> setAwayEverywhere(true, preset));
menu.add(item);
}
if (!awayMessages.all().isEmpty()) menu.addSeparator();
JMenuItem manage = new JMenuItem("Manage away messages…", Icons.of("EDIT"));
manage.addActionListener(e -> new AwayMessagesDialog(this, awayMessages, null).setVisible(true));
menu.add(manage);
return menu;
}
private void toggleAway() {
if (selected == null) return;
boolean away = awayItem.isSelected();
String message = null;
if (away) {
message = JOptionPane.showInputDialog(this, "Away message (optional):", "");
if (message == null) { // cancelled
awayItem.setSelected(false);
return;
}
selected.setAway(awayItem.isSelected(), "");
updateToolbar();
}
/** Sets the away message on the selected server only. */
private void setAwayStatus() {
if (selected == null) return;
String message = askAwayMessage(selected.awayMessage());
if (message == null) return;
selected.setAway(true, message);
updateToolbar();
}
private void setAwayEverywhere(boolean away, String message) {
for (ServerTab tab : tabs) {
if (tab.isConnected()) tab.setAway(away, message);
}
selected.setAway(away, message);
updateToolbar();
}
/** @return true when every connected server is marked away (and there is one) */
private boolean isGloballyAway() {
boolean any = false;
for (ServerTab tab : tabs) {
if (!tab.isConnected()) continue;
any = true;
if (!tab.isAway()) return false;
}
return any;
}
/** The message to preload the prompt with: the selected tab's, else any set one. */
private String currentAwayMessage() {
if (selected != null && !selected.awayMessage().isEmpty()) return selected.awayMessage();
for (ServerTab tab : tabs) {
if (tab.isConnected() && !tab.awayMessage().isEmpty()) return tab.awayMessage();
}
return "";
}
/** @return the entered message (possibly empty), or null when cancelled */
private String askAwayMessage(String initial) {
return (String) JOptionPane.showInputDialog(this, "Away message (optional):", "Away status",
JOptionPane.PLAIN_MESSAGE, Icons.of("AWAY"), null, initial);
}
private void doDisconnect() {
@@ -586,6 +673,10 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
speakerButton.setEnabled(connected);
activeButton.setEnabled(connected);
awayItem.setEnabled(connected);
awayStatusItem.setEnabled(connected);
// The menu's actions are global, so the arrow stays live while any server is up.
awayButton.setEnabled(tabs.stream().anyMatch(ServerTab::isConnected));
awayButton.setToggleEnabled(connected);
commanderItem.setEnabled(connected);
boolean micMuted = connected && selected.isMicMuted();
@@ -595,7 +686,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener {
speakerButton.setSelected(deaf);
speakerButton.setIcon(deaf ? Icons.speakerMutedLarge() : Icons.speaker());
activeButton.setSelected(selected != null && selected == micTab);
awayItem.setSelected(connected && selected.isAway());
boolean away = connected && selected.isAway();
awayItem.setSelected(away);
awayButton.setSelected(away);
commanderItem.setSelected(connected && selected.isCommander());
}

View File

@@ -49,6 +49,8 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
private boolean micMuted;
private boolean deafened;
private boolean away;
/** Away message currently published, empty when away carries no message. */
private String awayMessage = "";
private boolean commander;
private Object currentSelection;
@@ -143,6 +145,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
return away;
}
String awayMessage() {
return awayMessage;
}
boolean isCommander() {
return commander;
}
@@ -223,9 +229,16 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
conn.setMicrophoneActive(active);
}
/**
* @param message the away message, or null to keep the one already set — the
* message outlives coming back, so toggling away again restores it
*/
void setAway(boolean away, String message) {
this.away = away;
conn.setAway(away, message);
if (message != null) this.awayMessage = message;
conn.setAway(away, awayMessage);
chatPanel.appendSystem(!away ? "No longer away."
: awayMessage.isEmpty() ? "Away." : "Away: " + awayMessage);
}
void setCommander(boolean commander) {
@@ -394,6 +407,7 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions {
micMuted = false;
deafened = false;
away = false;
awayMessage = "";
commander = false;
chatPanel.setInputEnabled(true);
chatPanel.appendSystem("Connected.");

View File

@@ -590,11 +590,7 @@ public final class ServerTreePanel extends JScrollPane {
} else if (obj instanceof ClientEntry) {
ClientEntry cl = (ClientEntry) obj;
String label = cl.nickname;
if (badgesOf(cl).isEmpty()) {
// No icons (yet): fall back to naming the primary group inline.
String primaryGroup = model.primaryServerGroupName(cl.serverGroupIds);
if (primaryGroup != null) label += " [" + primaryGroup + "]";
}
if (cl.away && !cl.awayMessage.isEmpty()) label += " [" + cl.awayMessage + "]";
setText(label);
setIcon(iconFor(cl));
setForeground(cl.talking ? Theme.TALKING : Theme.TREE_TEXT);