Swing never grabs the pointer for a popup, so a click on the desktop or another application is not seen and the menu stays up. The little window the menu hangs off is focusable now and closes it when the focus goes elsewhere, Escape closes it, and clicking the icon again toggles it. Raising the window was ignored for the same reason it usually is: a window manager refuses a raise from an application that does not have the focus. The icon asks for _NET_ACTIVE_WINDOW instead — the request meant for pagers and trays acting for the user — and falls back to Swing's always-on-top shuffle when it cannot find the window. Windows are matched by _NET_WM_PID and _NET_WM_NAME, since WM_NAME cannot carry the em dash in our title. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
232 lines
8.5 KiB
Java
232 lines
8.5 KiB
Java
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.JComponent;
|
|
import javax.swing.JDialog;
|
|
import javax.swing.JMenuItem;
|
|
import javax.swing.JPopupMenu;
|
|
import javax.swing.KeyStroke;
|
|
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.awt.Window;
|
|
import java.awt.event.KeyEvent;
|
|
import java.awt.event.WindowAdapter;
|
|
import java.awt.event.WindowEvent;
|
|
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 = "";
|
|
|
|
/** The context menu while it is on screen, and the window it hangs off. */
|
|
private JPopupMenu openMenu;
|
|
private JDialog openAnchor;
|
|
|
|
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.
|
|
*
|
|
* <p>Swing dismisses a popup from events it sees itself, and it sees none of the
|
|
* clicks that land on the desktop or another application, so the little window is
|
|
* made focusable and the menu closed as soon as it loses the focus. Escape and a
|
|
* second click on the icon close it too.
|
|
*/
|
|
private void showMenu(int x, int y) {
|
|
if (openMenu != null) { // a second click on the icon: close it again
|
|
hideMenu();
|
|
return;
|
|
}
|
|
JDialog anchor = new JDialog(frame);
|
|
anchor.setUndecorated(true);
|
|
anchor.setFocusableWindowState(true);
|
|
anchor.setAlwaysOnTop(true);
|
|
anchor.setLocation(x, y);
|
|
anchor.setSize(1, 1);
|
|
anchor.setVisible(true);
|
|
anchor.toFront();
|
|
anchor.requestFocus();
|
|
anchor.addWindowFocusListener(new WindowAdapter() {
|
|
@Override
|
|
public void windowLostFocus(WindowEvent e) {
|
|
// The menu's own popup window may take the focus instead; only the
|
|
// focus moving away from the menu altogether closes it.
|
|
if (!ownedBy(e.getOppositeWindow(), anchor)) hideMenu();
|
|
}
|
|
});
|
|
|
|
anchor.getRootPane().registerKeyboardAction(e -> hideMenu(),
|
|
KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0),
|
|
JComponent.WHEN_IN_FOCUSED_WINDOW);
|
|
|
|
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) {
|
|
closed(anchor);
|
|
}
|
|
|
|
@Override
|
|
public void popupMenuCanceled(PopupMenuEvent e) {
|
|
closed(anchor);
|
|
}
|
|
});
|
|
openMenu = menu;
|
|
openAnchor = anchor;
|
|
menu.show(anchor, 0, 0);
|
|
}
|
|
|
|
/** @return whether {@code window} is {@code owner} or one of its child windows */
|
|
private static boolean ownedBy(Window window, Window owner) {
|
|
for (Window w = window; w != null; w = w.getOwner()) {
|
|
if (w == owner) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private void hideMenu() {
|
|
if (openMenu != null) openMenu.setVisible(false); // the listener cleans up
|
|
closed(openAnchor);
|
|
}
|
|
|
|
private void closed(JDialog anchor) {
|
|
openMenu = null;
|
|
openAnchor = null;
|
|
if (anchor != null) anchor.dispose();
|
|
}
|
|
|
|
/**
|
|
* Brings the window back from the taskbar or an iconified state.
|
|
*
|
|
* <p>A window manager ignores {@link Frame#toFront()} from an application that is
|
|
* not focused, which is every time the tray icon is used, so the backend is asked
|
|
* first — it can make the request the window manager does honour.
|
|
*/
|
|
private void show() {
|
|
hideMenu();
|
|
frame.setVisible(true);
|
|
frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED);
|
|
// Deferred so the window is mapped before anything tries to raise it.
|
|
SwingUtilities.invokeLater(() -> {
|
|
if (tray != null && tray.activateWindow(frame.getTitle())) return;
|
|
// Whatever Swing can manage: asking to be on top for a moment gets the
|
|
// window in front even where a plain raise is refused.
|
|
frame.setAlwaysOnTop(true);
|
|
frame.toFront();
|
|
frame.requestFocus();
|
|
frame.setAlwaysOnTop(false);
|
|
});
|
|
}
|
|
}
|