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.TrayBackend;
import com.ts3client.ui.tray.X11Tray; import com.ts3client.ui.tray.X11Tray;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JMenuItem; import javax.swing.JMenuItem;
import javax.swing.JPopupMenu; import javax.swing.JPopupMenu;
import javax.swing.JWindow; import javax.swing.KeyStroke;
import javax.swing.SwingUtilities; import javax.swing.SwingUtilities;
import javax.swing.event.PopupMenuEvent; import javax.swing.event.PopupMenuEvent;
import javax.swing.event.PopupMenuListener; import javax.swing.event.PopupMenuListener;
@@ -14,6 +16,10 @@ import java.awt.Frame;
import java.awt.Image; import java.awt.Image;
import java.awt.MenuItem; import java.awt.MenuItem;
import java.awt.PopupMenu; 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; import java.util.function.IntFunction;
/** /**
@@ -37,6 +43,10 @@ final class TrayController {
private SelfState state; private SelfState state;
private String tooltip = ""; 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) { TrayController(MainFrame frame, Runnable onQuit) {
this.frame = frame; this.frame = frame;
this.onQuit = onQuit; 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 * 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. * 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) { 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.setLocation(x, y);
anchor.setSize(1, 1); anchor.setSize(1, 1);
anchor.setVisible(true); 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(); JPopupMenu menu = new JPopupMenu();
JMenuItem showItem = new JMenuItem("Show TS3J", Icons.app()); JMenuItem showItem = new JMenuItem("Show TS3J", Icons.app());
@@ -138,22 +174,58 @@ final class TrayController {
@Override @Override
public void popupMenuWillBecomeInvisible(PopupMenuEvent e) { public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
anchor.dispose(); closed(anchor);
} }
@Override @Override
public void popupMenuCanceled(PopupMenuEvent e) { public void popupMenuCanceled(PopupMenuEvent e) {
anchor.dispose(); closed(anchor);
} }
}); });
openMenu = menu;
openAnchor = anchor;
menu.show(anchor, 0, 0); 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() { private void show() {
hideMenu();
frame.setVisible(true); frame.setVisible(true);
frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED); 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.toFront();
frame.requestFocus(); frame.requestFocus();
frame.setAlwaysOnTop(false);
});
} }
} }

View File

@@ -23,6 +23,19 @@ public interface TrayBackend {
/** How this icon is being shown, for the client's own diagnostics. */ /** How this icon is being shown, for the client's own diagnostics. */
String description(); String description();
/**
* Asks the window manager to bring a window of this application to the front.
* Swing cannot: a window manager ignores a raise from an application that does
* not have the focus, which is exactly the case when the tray icon is used.
*
* @param title the window's title
* @return {@code false} when this backend cannot ask, and the caller should try
* whatever Swing can do
*/
default boolean activateWindow(String title) {
return false;
}
/** What the user did with the icon. */ /** What the user did with the icon. */
interface Listener { interface Listener {

View File

@@ -47,6 +47,8 @@ final class X11 {
static final long CW_COLORMAP = 1L << 13; static final long CW_COLORMAP = 1L << 13;
static final long BUTTON_PRESS_MASK = 1L << 2; static final long BUTTON_PRESS_MASK = 1L << 2;
static final long SUBSTRUCTURE_NOTIFY_MASK = 1L << 19;
static final long SUBSTRUCTURE_REDIRECT_MASK = 1L << 20;
static final long EXPOSURE_MASK = 1L << 15; static final long EXPOSURE_MASK = 1L << 15;
static final long STRUCTURE_NOTIFY_MASK = 1L << 17; static final long STRUCTURE_NOTIFY_MASK = 1L << 17;
static final long NO_EVENT_MASK = 0L; static final long NO_EVENT_MASK = 0L;
@@ -63,6 +65,8 @@ final class X11 {
static final long XA_VISUALID = 32; static final long XA_VISUALID = 32;
static final long VISUAL_ID_MASK = 0x1; static final long VISUAL_ID_MASK = 0x1;
/** {@code AnyPropertyType}: read a property whatever its type. */
static final long ANY_PROPERTY_TYPE = 0;
// ---- structure layout ---- // ---- structure layout ----
@@ -160,6 +164,8 @@ final class X11 {
downcall("XSelectInput", FunctionDescriptor.of(INT, PTR, LONG, LONG)); downcall("XSelectInput", FunctionDescriptor.of(INT, PTR, LONG, LONG));
static final MethodHandle X_SEND_EVENT = static final MethodHandle X_SEND_EVENT =
downcall("XSendEvent", FunctionDescriptor.of(INT, PTR, LONG, INT, LONG, PTR)); downcall("XSendEvent", FunctionDescriptor.of(INT, PTR, LONG, INT, LONG, PTR));
static final MethodHandle X_QUERY_TREE =
downcall("XQueryTree", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR, PTR, PTR));
static final MethodHandle X_SET_WM_NORMAL_HINTS = static final MethodHandle X_SET_WM_NORMAL_HINTS =
downcall("XSetWMNormalHints", FunctionDescriptor.ofVoid(PTR, LONG, PTR)); downcall("XSetWMNormalHints", FunctionDescriptor.ofVoid(PTR, LONG, PTR));
static final MethodHandle X_RESIZE_WINDOW = static final MethodHandle X_RESIZE_WINDOW =
@@ -287,6 +293,59 @@ final class X11 {
data, bytes.length); data, bytes.length);
} }
/** @return the children of a window, oldest first */
static long[] children(MemorySegment display, Arena arena, long window) {
MemorySegment rootReturn = arena.allocate(LONG);
MemorySegment parentReturn = arena.allocate(LONG);
MemorySegment childrenReturn = arena.allocate(PTR);
MemorySegment countReturn = arena.allocate(INT);
int status = (int) call(Handles.X_QUERY_TREE, display, window, rootReturn, parentReturn,
childrenReturn, countReturn);
MemorySegment list = childrenReturn.get(PTR, 0);
if (status == 0 || list.equals(MemorySegment.NULL)) return new long[0];
int count = countReturn.get(INT, 0);
long[] ids = new long[count];
MemorySegment windows = list.reinterpret((long) count * Long.BYTES);
for (int i = 0; i < count; i++) ids[i] = windows.getAtIndex(LONG, i);
call(Handles.X_FREE, list);
return ids;
}
/**
* Reads a text property.
*
* @return its value as UTF-8, or {@code null} when the window has no such property
*/
static String textProperty(MemorySegment display, Arena arena, long window, long property) {
MemorySegment actualType = arena.allocate(LONG);
MemorySegment actualFormat = arena.allocate(INT);
MemorySegment items = arena.allocate(LONG);
MemorySegment bytesAfter = arena.allocate(LONG);
MemorySegment data = arena.allocate(PTR);
int status = (int) call(Handles.X_GET_WINDOW_PROPERTY, display, window, property, 0L, 1024L,
0, ANY_PROPERTY_TYPE, actualType, actualFormat, items, bytesAfter, data);
MemorySegment value = data.get(PTR, 0);
if (status != SUCCESS || value.equals(MemorySegment.NULL)) return null;
long length = items.get(LONG, 0);
byte[] bytes = value.reinterpret(length).toArray(ValueLayout.JAVA_BYTE);
call(Handles.X_FREE, value);
return new String(bytes, StandardCharsets.UTF_8);
}
/** @return {@code true} when the window carries the property at all */
static boolean hasProperty(MemorySegment display, Arena arena, long window, long property) {
MemorySegment actualType = arena.allocate(LONG);
MemorySegment actualFormat = arena.allocate(INT);
MemorySegment items = arena.allocate(LONG);
MemorySegment bytesAfter = arena.allocate(LONG);
MemorySegment data = arena.allocate(PTR);
int status = (int) call(Handles.X_GET_WINDOW_PROPERTY, display, window, property, 0L, 0L, 0,
ANY_PROPERTY_TYPE, actualType, actualFormat, items, bytesAfter, data);
MemorySegment value = data.get(PTR, 0);
if (!value.equals(MemorySegment.NULL)) call(Handles.X_FREE, value);
return status == SUCCESS && actualType.get(LONG, 0) != 0;
}
// ---- visuals ---- // ---- visuals ----
/** /**

View File

@@ -9,6 +9,7 @@ import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment; import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout; import java.lang.foreign.ValueLayout;
import java.util.function.IntFunction; import java.util.function.IntFunction;
import java.util.function.LongPredicate;
/** /**
* A tray icon docked into the panel's notification area by hand, over the * A tray icon docked into the panel's notification area by hand, over the
@@ -263,6 +264,77 @@ public final class X11Tray implements TrayBackend {
} }
} }
/**
* Sends the window manager an {@code _NET_ACTIVE_WINDOW} request for the named
* window — the EWMH way of saying "the user asked for this window", which is
* honoured where a plain raise is not.
*/
@Override
public boolean activateWindow(String title) {
synchronized (this) {
if (!running) return false;
long target = findWindow(title);
if (target == 0) {
TrayLog.debug("no window named \"" + title + "\" to activate");
return false;
}
TrayLog.debug("activating window 0x" + Long.toHexString(target));
MemorySegment event = arena.allocate(X11.EVENT_SIZE);
event.fill((byte) 0);
event.set(ValueLayout.JAVA_INT, X11.EVENT_TYPE, X11.CLIENT_MESSAGE);
event.set(ValueLayout.JAVA_LONG, X11.EVENT_WINDOW, target);
event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_TYPE,
X11.internAtom(display, arena, "_NET_ACTIVE_WINDOW"));
event.set(ValueLayout.JAVA_INT, X11.CLIENT_MESSAGE_FORMAT, 32);
// Source indication 2: a pager or tray acting for the user, which window
// managers accept without their focus-stealing rules getting in the way.
event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA, 2);
X11.sendEvent(display, root,
X11.SUBSTRUCTURE_NOTIFY_MASK | X11.SUBSTRUCTURE_REDIRECT_MASK, event);
X11.flush(display);
return true;
}
}
/**
* Finds one of this process' own top-level windows: the one with the given title
* for choice, else any window the window manager is managing for us.
*
* <p>Windows are matched by {@code _NET_WM_PID} rather than by name alone, and
* the title is read from {@code _NET_WM_NAME} because {@code WM_NAME} cannot
* carry anything outside Latin-1.
*/
private long findWindow(String title) {
long pid = ProcessHandle.current().pid();
long pidAtom = X11.internAtom(display, arena, "_NET_WM_PID");
long nameAtom = X11.internAtom(display, arena, "_NET_WM_NAME");
long stateAtom = X11.internAtom(display, arena, "WM_STATE");
long found = findWindow(root, 0, w ->
X11.cardinalProperty(display, arena, w, pidAtom, X11.ANY_PROPERTY_TYPE) == pid
&& title.equals(X11.textProperty(display, arena, w, nameAtom)));
if (found != 0) return found;
return findWindow(root, 0, w ->
X11.cardinalProperty(display, arena, w, pidAtom, X11.ANY_PROPERTY_TYPE) == pid
&& X11.hasProperty(display, arena, w, stateAtom));
}
/**
* Walks the window tree below {@code parent}. Only the top few levels are looked
* at: a reparenting window manager puts client windows one or two frames below
* the root.
*/
private long findWindow(long parent, int depth, LongPredicate match) {
if (depth > 3) return 0;
for (long child : X11.children(display, arena, parent)) {
if (match.test(child)) return child;
long found = findWindow(child, depth + 1, match);
if (found != 0) return found;
}
return 0;
}
@Override @Override
public String description() { public String description() {
return "docked into the notification area, " + width + "x" + height + ", transparent"; return "docked into the notification area, " + width + "x" + height + ", transparent";