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. * *

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 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. * *

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. * *

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); }); } }