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