Show the local client's state in the system tray
The icon carries the badge the tree draws next to our own nickname on the active server — idle, talking, away, commander, microphone or speakers muted — and clicking it brings the window back to the front. Right click offers "Show TS3J" and "Quit". AWT's tray icon cannot be transparent on X11: the toolkit embeds a window of the screen's default, opaque visual and fills it with a background colour before drawing the image, so every icon sits in a box. Panels that can show transparent icons advertise an ARGB visual instead, so the icon is docked by hand over the system tray protocol, in that visual, with the image put on the window premultiplied. AWT's own icon stays as the fallback for everything else. Two details a panel will not forgive: an icon that publishes no size hints is allocated a one-pixel sliver, and docking is a request, so an icon that is never adopted has to hand over to the fallback rather than sit invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.ts3client.ui.tray.AwtTray;
|
||||
import com.ts3client.ui.tray.TrayBackend;
|
||||
import com.ts3client.ui.tray.X11Tray;
|
||||
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.JWindow;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.event.PopupMenuEvent;
|
||||
import javax.swing.event.PopupMenuListener;
|
||||
import java.awt.Frame;
|
||||
import java.awt.Image;
|
||||
import java.awt.MenuItem;
|
||||
import java.awt.PopupMenu;
|
||||
import java.util.function.IntFunction;
|
||||
|
||||
/**
|
||||
* The system tray icon. It shows the local client's state on the active server —
|
||||
* the same badge the tree draws next to a nickname, so it turns green while
|
||||
* speaking and into the muted microphone or speaker when muted — and brings the
|
||||
* window back to the front when clicked.
|
||||
*
|
||||
* <p>The icon is docked by {@link X11Tray} where the panel can show a transparent
|
||||
* one, and by {@link AwtTray} everywhere else. On a desktop with no tray at all
|
||||
* nothing is installed and every call here is a no-op.
|
||||
*/
|
||||
final class TrayController {
|
||||
|
||||
private final MainFrame frame;
|
||||
private final Runnable onQuit;
|
||||
|
||||
private final TrayBackend tray;
|
||||
|
||||
/** What is on screen now, so repeated updates don't touch the tray. */
|
||||
private SelfState state;
|
||||
private String tooltip = "";
|
||||
|
||||
TrayController(MainFrame frame, Runnable onQuit) {
|
||||
this.frame = frame;
|
||||
this.onQuit = onQuit;
|
||||
this.tray = install();
|
||||
}
|
||||
|
||||
/** What happened when the icon was installed, for the diagnostics in the chat log. */
|
||||
String status() {
|
||||
return tray == null
|
||||
? "System tray: no icon could be installed (run with -Dts3j.tray.debug=true for why)."
|
||||
: "System tray: " + tray.description() + ".";
|
||||
}
|
||||
|
||||
/** @return the backend that took the icon, or {@code null} when none could */
|
||||
private TrayBackend install() {
|
||||
TrayBackend.Listener listener = new TrayBackend.Listener() {
|
||||
@Override
|
||||
public void activated() {
|
||||
SwingUtilities.invokeLater(TrayController.this::show);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void menuRequested(int x, int y) {
|
||||
SwingUtilities.invokeLater(() -> showMenu(x, y));
|
||||
}
|
||||
};
|
||||
// -Dts3j.tray=awt|x11 pins the backend; by default the transparent one is
|
||||
// tried first and AWT's picks up whatever it leaves.
|
||||
String choice = System.getProperty("ts3j.tray", "auto");
|
||||
IntFunction<Image> initial = size -> SelfState.DISCONNECTED.icon(size).getImage();
|
||||
TrayBackend backend = choice.equals("awt") ? null : X11Tray.create(listener, initial);
|
||||
if (backend == null && !choice.equals("x11")) {
|
||||
backend = AwtTray.create(listener, buildAwtMenu(), initial);
|
||||
}
|
||||
return backend;
|
||||
}
|
||||
|
||||
/** Shows the state of the given server, named by {@code serverName} in the tooltip. */
|
||||
void update(SelfState newState, String serverName) {
|
||||
if (tray == null) return;
|
||||
String newTooltip = "TS3J — " + (serverName == null || serverName.isBlank()
|
||||
? newState.label()
|
||||
: serverName + ": " + newState.label());
|
||||
if (newState == state && newTooltip.equals(tooltip)) return;
|
||||
state = newState;
|
||||
tooltip = newTooltip;
|
||||
tray.setIcon(size -> newState.icon(size).getImage());
|
||||
tray.setTooltip(newTooltip);
|
||||
}
|
||||
|
||||
/** Redraws after the icon pack changed. */
|
||||
void refreshIcon() {
|
||||
if (tray == null || state == null) return;
|
||||
SelfState current = state;
|
||||
tray.setIcon(size -> current.icon(size).getImage());
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (tray != null) tray.dispose();
|
||||
}
|
||||
|
||||
// ---- menus ----
|
||||
|
||||
/** The menu for AWT's icon, which shows one of its own. */
|
||||
private PopupMenu buildAwtMenu() {
|
||||
PopupMenu menu = new PopupMenu();
|
||||
MenuItem showItem = new MenuItem("Show TS3J");
|
||||
showItem.addActionListener(e -> SwingUtilities.invokeLater(this::show));
|
||||
MenuItem quitItem = new MenuItem("Quit");
|
||||
quitItem.addActionListener(e -> SwingUtilities.invokeLater(onQuit));
|
||||
menu.add(showItem);
|
||||
menu.addSeparator();
|
||||
menu.add(quitItem);
|
||||
return menu;
|
||||
}
|
||||
|
||||
/**
|
||||
* The same menu for backends that have none — a Swing popup needs a component to
|
||||
* hang off, so it is given an empty window at the pointer.
|
||||
*/
|
||||
private void showMenu(int x, int y) {
|
||||
JWindow anchor = new JWindow(frame);
|
||||
anchor.setLocation(x, y);
|
||||
anchor.setSize(1, 1);
|
||||
anchor.setVisible(true);
|
||||
|
||||
JPopupMenu menu = new JPopupMenu();
|
||||
JMenuItem showItem = new JMenuItem("Show TS3J", Icons.app());
|
||||
showItem.addActionListener(e -> show());
|
||||
JMenuItem quitItem = new JMenuItem("Quit", Icons.of("QUIT"));
|
||||
quitItem.addActionListener(e -> onQuit.run());
|
||||
menu.add(showItem);
|
||||
menu.addSeparator();
|
||||
menu.add(quitItem);
|
||||
menu.addPopupMenuListener(new PopupMenuListener() {
|
||||
@Override
|
||||
public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
|
||||
anchor.dispose();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void popupMenuCanceled(PopupMenuEvent e) {
|
||||
anchor.dispose();
|
||||
}
|
||||
});
|
||||
menu.show(anchor, 0, 0);
|
||||
}
|
||||
|
||||
/** Brings the window back from the taskbar or an iconified state. */
|
||||
private void show() {
|
||||
frame.setVisible(true);
|
||||
frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED);
|
||||
frame.toFront();
|
||||
frame.requestFocus();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user