Make the tray menu close, and its window come forward

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>
This commit is contained in:
2026-08-14 22:58:24 +00:00
parent 32e5ffb576
commit 3e2c06248f
4 changed files with 223 additions and 7 deletions

View File

@@ -4,9 +4,11 @@ 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.JWindow;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener;
@@ -14,6 +16,10 @@ 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;
/**
@@ -37,6 +43,10 @@ final class TrayController {
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;
@@ -116,12 +126,38 @@ final class TrayController {
/**
* 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) {
JWindow anchor = new JWindow(frame);
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());
@@ -138,22 +174,58 @@ final class TrayController {
@Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
anchor.dispose();
closed(anchor);
}
@Override
public void popupMenuCanceled(PopupMenuEvent e) {
anchor.dispose();
closed(anchor);
}
});
openMenu = menu;
openAnchor = anchor;
menu.show(anchor, 0, 0);
}
/** Brings the window back from the taskbar or an iconified state. */
/** @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);
frame.toFront();
frame.requestFocus();
// 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);
});
}
}