diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java index f0d84e6..5b97760 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java @@ -55,7 +55,7 @@ public final class Icons { private static ImageIcon themed(String key, int size, Painter fallback) { ImageIcon icon = IconTheme.icon(key, size); - return icon != null ? icon : make(fallback); + return icon != null ? icon : make(size, fallback); } /** @@ -71,10 +71,16 @@ public final class Icons { } private static ImageIcon make(Painter p) { - BufferedImage img = new BufferedImage(SZ, SZ, BufferedImage.TYPE_INT_ARGB); + return make(SZ, p); + } + + /** Runs a painter (which draws in a 16×16 box) scaled to {@code size}. */ + private static ImageIcon make(int size, Painter p) { + BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB); Graphics2D g = img.createGraphics(); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE); + g.scale(size / (double) SZ, size / (double) SZ); p.paint(g); g.dispose(); return new ImageIcon(img); @@ -111,25 +117,37 @@ public final class Icons { // ---- client status icons ---- public static ImageIcon clientIdle() { - return themed("PLAYER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); + return clientIdle(SZ); + } + + public static ImageIcon clientIdle(int size) { + return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); } public static ImageIcon clientTalking() { - return themed("PLAYER_ON", g -> paintPerson(g, Theme.TALKING)); + return clientTalking(SZ); + } + + public static ImageIcon clientTalking(int size) { + return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.TALKING)); } public static ImageIcon clientAway() { - return themed("AWAY", g -> paintPerson(g, Theme.AWAY)); + return clientAway(SZ); + } + + public static ImageIcon clientAway(int size) { + return themed("AWAY", size, g -> paintPerson(g, Theme.AWAY)); } /** A channel commander that is not talking. */ public static ImageIcon clientCommander() { - return themed("PLAYER_COMMANDER_OFF", g -> paintPerson(g, Theme.IDLE_CLIENT)); + return clientCommander(SZ); } /** A channel commander that is talking. */ public static ImageIcon clientCommanderTalking() { - return themed("PLAYER_COMMANDER_ON", g -> paintPerson(g, Theme.TALKING)); + return clientCommanderTalking(SZ); } public static ImageIcon clientQuery() { @@ -137,11 +155,29 @@ public final class Icons { } public static ImageIcon micMuted() { - return themed("INPUT_MUTED", Icons::paintMicMuted); + return micMuted(SZ); + } + + public static ImageIcon micMuted(int size) { + return themed("INPUT_MUTED", size, Icons::paintMicMuted); } public static ImageIcon speakerMuted() { - return themed("OUTPUT_MUTED", Icons::paintSpeakerMuted); + return speakerMuted(SZ); + } + + public static ImageIcon speakerMuted(int size) { + return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted); + } + + /** A channel commander that is not talking. */ + public static ImageIcon clientCommander(int size) { + return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.IDLE_CLIENT)); + } + + /** A channel commander that is talking. */ + public static ImageIcon clientCommanderTalking(int size) { + return themed("PLAYER_COMMANDER_ON", size, g -> paintPerson(g, Theme.TALKING)); } // ---- toolbar / action icons ---- @@ -190,7 +226,11 @@ public final class Icons { } public static ImageIcon app() { - return make(Icons::paintApp); + return app(SZ); + } + + public static ImageIcon app(int size) { + return make(size, Icons::paintApp); } // ---- built-in painters ---- diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index 95ec9a5..5bca4c1 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -76,6 +76,9 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { private final JLabel statusLabel = new JLabel("Not connected"); private final JLabel codecLabel = new JLabel(); + /** Mirrors the active server's own client state next to the clock. */ + private TrayController tray; + private JToolBar toolbar; private JButton connectButton; private JButton disconnectButton; @@ -104,14 +107,14 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { - shutdown(); - System.exit(0); + quit(); } }); // Catches Ctrl+C / SIGTERM so we still leave the servers cleanly. Runtime.getRuntime().addShutdownHook(shutdownHook); setMinimumSize(new Dimension(720, 480)); + tray = new TrayController(this, this::quit); setJMenuBar(buildMenuBar()); toolbar = buildToolbar(); @@ -127,6 +130,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { selectTab(first); first.chat().appendSystem("Welcome to the TS3J Swing client."); first.chat().appendSystem("Use Connections → Connect to join a server."); + first.chat().appendSystem(tray.status()); installPushToTalk(); statusTimer = new javax.swing.Timer(3000, e -> updateConnectionStatus()); @@ -158,10 +162,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { closeTab.setAccelerator(KeyStroke.getKeyStroke("control W")); closeTab.addActionListener(e -> closeTab(selected)); JMenuItem quit = new JMenuItem("Quit", Icons.of("QUIT")); - quit.addActionListener(e -> { - shutdown(); - System.exit(0); - }); + quit.addActionListener(e -> quit()); connections.add(connect); connections.add(disconnect); connections.add(closeTab); @@ -307,6 +308,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { add(toolbar, BorderLayout.NORTH); updateToolbar(); refreshTabs(); + if (tray != null) tray.refreshIcon(); revalidate(); repaint(); } @@ -376,6 +378,7 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { ServerTab previous = micTab; micTab = tab; refreshTabs(); + updateTray(); // Closing and reopening the capture line can block briefly; keep it off the EDT. new Thread(() -> { if (previous != null) previous.setMicrophoneActive(false); @@ -411,6 +414,12 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { updateToolbar(); updateStatusLabel(); } + updateTray(); + } + + /** The local client on {@code tab} started or stopped talking, muted itself, … */ + void selfStateChanged(ServerTab tab) { + if (tabs.contains(tab)) updateTray(); } void tabConnected(ServerTab tab) { @@ -618,6 +627,13 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { tab.shutdown(); } soundPlayer.shutdown(); + if (tray != null) tray.dispose(); + } + + /** Leaves every server and ends the process. */ + private void quit() { + shutdown(); + System.exit(0); } private void showIdentities() { @@ -690,6 +706,26 @@ public final class MainFrame extends JFrame implements ServerTabPane.Listener { awayItem.setSelected(away); awayButton.setSelected(away); commanderItem.setSelected(connected && selected.isCommander()); + updateTray(); + } + + /** + * The server the tray icon speaks for: the one holding the microphone, or the + * visible one when nobody is capturing. + */ + private ServerTab trayTab() { + if (micTab != null && micTab.isConnected()) return micTab; + return selected != null && selected.isConnected() ? selected : null; + } + + private void updateTray() { + if (tray == null) return; + ServerTab tab = trayTab(); + if (tab == null) { + tray.update(SelfState.DISCONNECTED, null); + } else { + tray.update(tab.selfState(), tab.title()); + } } private void updateStatusLabel() { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java new file mode 100644 index 0000000..e09c852 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/SelfState.java @@ -0,0 +1,52 @@ +package com.ts3client.ui; + +import javax.swing.ImageIcon; + +/** + * The state of the local client on one server, as the tree shows it for any other + * client — the states are listed in the order the official client gives them + * priority, the strongest one first. + */ +enum SelfState { + + DISCONNECTED("Not connected"), + DEAFENED("Speakers muted"), + MIC_MUTED("Microphone muted"), + AWAY("Away"), + COMMANDER_TALKING("Talking (channel commander)"), + COMMANDER("Channel commander"), + TALKING("Talking"), + IDLE("Connected"); + + private final String label; + + SelfState(String label) { + this.label = label; + } + + String label() { + return label; + } + + /** The icon this state is drawn with, from the active icon pack. */ + ImageIcon icon(int size) { + switch (this) { + case DISCONNECTED: + return Icons.app(size); + case DEAFENED: + return Icons.speakerMuted(size); + case MIC_MUTED: + return Icons.micMuted(size); + case AWAY: + return Icons.clientAway(size); + case COMMANDER_TALKING: + return Icons.clientCommanderTalking(size); + case COMMANDER: + return Icons.clientCommander(size); + case TALKING: + return Icons.clientTalking(size); + default: + return Icons.clientIdle(size); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java index 3b702dc..026464a 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTab.java @@ -153,6 +153,18 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { return commander; } + /** What the local client looks like on this server, for the tray icon. */ + SelfState selfState() { + if (!conn.isConnected()) return SelfState.DISCONNECTED; + if (deafened) return SelfState.DEAFENED; + if (micMuted) return SelfState.MIC_MUTED; + if (away) return SelfState.AWAY; + ClientEntry self = conn.getModel().getClient(conn.getSelfClientId()); + boolean talking = self != null && self.talking; + if (commander) return talking ? SelfState.COMMANDER_TALKING : SelfState.COMMANDER; + return talking ? SelfState.TALKING : SelfState.IDLE; + } + /** Path of the channel we are in, or empty when not connected. */ String currentChannelPath() { if (!conn.isConnected()) return ""; @@ -511,7 +523,10 @@ final class ServerTab implements ConnectionListener, ServerTreePanel.Actions { @Override public void onTalkStateChanged(int clientId, boolean talking) { - SwingUtilities.invokeLater(treePanel::refreshVisual); + SwingUtilities.invokeLater(() -> { + treePanel.refreshVisual(); + if (clientId == conn.getSelfClientId()) host.selfStateChanged(this); + }); } @Override diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java new file mode 100644 index 0000000..8aa6cfc --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/TrayController.java @@ -0,0 +1,159 @@ +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.JMenuItem; +import javax.swing.JPopupMenu; +import javax.swing.JWindow; +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.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 = ""; + + 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. + */ + private void showMenu(int x, int y) { + JWindow anchor = new JWindow(frame); + anchor.setLocation(x, y); + anchor.setSize(1, 1); + anchor.setVisible(true); + + 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) { + anchor.dispose(); + } + + @Override + public void popupMenuCanceled(PopupMenuEvent e) { + anchor.dispose(); + } + }); + menu.show(anchor, 0, 0); + } + + /** Brings the window back from the taskbar or an iconified state. */ + private void show() { + frame.setVisible(true); + frame.setExtendedState(frame.getExtendedState() & ~Frame.ICONIFIED); + frame.toFront(); + frame.requestFocus(); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java new file mode 100644 index 0000000..d0074e1 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/AwtTray.java @@ -0,0 +1,90 @@ +package com.ts3client.ui.tray; + +import java.awt.AWTException; +import java.awt.Dimension; +import java.awt.Image; +import java.awt.PopupMenu; +import java.awt.SystemTray; +import java.awt.TrayIcon; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.function.IntFunction; + +/** + * The tray icon AWT itself provides — used where {@link X11Tray} cannot help: + * Windows and macOS, and X11 panels that do not offer a transparent visual. + * + *

On X11 the toolkit draws the icon onto an opaque background, which is the + * price of this fallback; the menu, on the other hand, is the desktop's own. + */ +public final class AwtTray implements TrayBackend { + + private final TrayIcon icon; + private final int size; + + /** + * @param menu the icon's context menu, or {@code null} to have right clicks + * reported to {@code listener} instead + * @param initial the image to dock with, rendered at the tray's icon size + * @return the icon, or {@code null} when this desktop has no tray at all + */ + public static AwtTray create(Listener listener, PopupMenu menu, IntFunction initial) { + if (!SystemTray.isSupported()) { + TrayLog.debug("this desktop has no system tray"); + return null; + } + try { + AwtTray tray = new AwtTray(listener, menu, initial); + TrayLog.debug("using AWT's tray icon, " + tray.size + "px (it draws a background)"); + return tray; + } catch (AWTException | UnsupportedOperationException e) { + TrayLog.debug("AWT would not add the icon: " + e.getMessage()); + return null; + } + } + + private AwtTray(Listener listener, PopupMenu menu, IntFunction initial) + throws AWTException { + Dimension traySize = SystemTray.getSystemTray().getTrayIconSize(); + size = Math.max(16, Math.min(traySize.width, traySize.height)); + + // Added with its final image and tooltip: some panels take the icon they are + // handed at that moment and ignore later changes. + icon = new TrayIcon(initial.apply(size), "TS3J"); + if (menu != null) icon.setPopupMenu(menu); + // The action event only fires on a double click (and not at all on some + // desktops), so listen for the click itself. + icon.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() == MouseEvent.BUTTON1) { + listener.activated(); + } else if (menu == null && e.isPopupTrigger()) { + listener.menuRequested(e.getXOnScreen(), e.getYOnScreen()); + } + } + }); + SystemTray.getSystemTray().add(icon); + } + + @Override + public void setIcon(IntFunction render) { + Image image = render.apply(size); + if (image != null) icon.setImage(image); + } + + @Override + public void setTooltip(String tooltip) { + icon.setToolTip(tooltip); + } + + @Override + public String description() { + return "AWT's own icon, " + size + "px (the desktop draws a background behind it)"; + } + + @Override + public void dispose() { + SystemTray.getSystemTray().remove(icon); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayBackend.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayBackend.java new file mode 100644 index 0000000..5d1b978 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayBackend.java @@ -0,0 +1,38 @@ +package com.ts3client.ui.tray; + +import java.awt.Image; +import java.util.function.IntFunction; + +/** A tray icon, however the desktop happens to provide one. */ +public interface TrayBackend { + + /** + * Sets the icon to show. + * + * @param render draws it at the size the tray asks for — panels differ, and the + * size can change while the icon is docked + */ + void setIcon(IntFunction render); + + /** Sets the text the panel shows on hover, where it supports one. */ + void setTooltip(String tooltip); + + /** Takes the icon out of the tray again. */ + void dispose(); + + /** How this icon is being shown, for the client's own diagnostics. */ + String description(); + + /** What the user did with the icon. */ + interface Listener { + + /** The icon was clicked (or activated from the keyboard). */ + void activated(); + + /** + * A context menu was asked for at a screen position. Only called by backends + * that have no menu of their own. + */ + void menuRequested(int x, int y); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayLog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayLog.java new file mode 100644 index 0000000..a9bb3c0 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/TrayLog.java @@ -0,0 +1,20 @@ +package com.ts3client.ui.tray; + +/** + * Diagnostics for the tray icon, off unless {@code -Dts3j.tray.debug=true} is given. + * + *

Which backend ends up with the icon depends on what the panel offers, and a + * panel that quietly ignores a docking request leaves nothing to see, so it is + * worth being able to ask. + */ +final class TrayLog { + + private static final boolean ENABLED = Boolean.getBoolean("ts3j.tray.debug"); + + private TrayLog() { + } + + static void debug(String message) { + if (ENABLED) System.err.println("tray: " + message); + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java new file mode 100644 index 0000000..a03847a --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java @@ -0,0 +1,411 @@ +package com.ts3client.ui.tray; + +import java.lang.foreign.AddressLayout; +import java.lang.foreign.Arena; +import java.lang.foreign.FunctionDescriptor; +import java.lang.foreign.Linker; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.SymbolLookup; +import java.lang.foreign.ValueLayout; +import java.lang.invoke.MethodHandle; +import java.nio.charset.StandardCharsets; + +/** + * Raw binding to {@code libX11} through the Foreign Function & Memory API, with + * just the calls {@link X11Tray} needs. + * + *

This class is the only place that knows about Xlib's ABI: the entry points, the + * protocol constants and the offsets of the few structures that are read or written + * directly. Loading is lazy and failure is expected — on a machine with no X11 (or a + * pure Wayland session) {@link #isAvailable()} returns {@code false} and the caller + * falls back to AWT's own tray icon. + * + *

Offsets are those of the LP64 ABI, in which {@code XID}, {@code Atom} and + * {@code long} are 64 bits wide and every field is naturally aligned. + */ +final class X11 { + + private X11() { + } + + static final AddressLayout PTR = ValueLayout.ADDRESS; + static final ValueLayout.OfInt INT = ValueLayout.JAVA_INT; + static final ValueLayout.OfLong LONG = ValueLayout.JAVA_LONG; + + // ---- protocol constants (X.h) ---- + + static final int INPUT_OUTPUT = 1; + static final int ALLOC_NONE = 0; + static final int PROP_MODE_REPLACE = 0; + static final int Z_PIXMAP = 2; + static final int SUCCESS = 0; + + /** Fields of {@code XSetWindowAttributes} passed to {@code XCreateWindow}. */ + static final long CW_BACK_PIXEL = 1L << 1; + static final long CW_BORDER_PIXEL = 1L << 3; + static final long CW_EVENT_MASK = 1L << 11; + static final long CW_COLORMAP = 1L << 13; + + static final long BUTTON_PRESS_MASK = 1L << 2; + static final long EXPOSURE_MASK = 1L << 15; + static final long STRUCTURE_NOTIFY_MASK = 1L << 17; + static final long NO_EVENT_MASK = 0L; + + static final int BUTTON_PRESS = 4; + static final int EXPOSE = 12; + static final int MAP_NOTIFY = 19; + static final int REPARENT_NOTIFY = 21; + static final int CONFIGURE_NOTIFY = 22; + static final int CLIENT_MESSAGE = 33; + + /** Predefined atoms (Xatom.h). */ + static final long XA_STRING = 31; + static final long XA_VISUALID = 32; + + static final long VISUAL_ID_MASK = 0x1; + + // ---- structure layout ---- + + /** {@code XEvent} is a union; 192 bytes is its size on every LP64 platform. */ + static final long EVENT_SIZE = 192; + static final long EVENT_TYPE = 0; + + /** {@code XExposeEvent} / {@code XConfigureEvent} / {@code XButtonEvent} share this prefix. */ + static final long EVENT_WINDOW = 32; + + /** {@code XConfigureEvent}: the reconfigured window, then its new geometry. */ + static final long CONFIGURE_WINDOW = 40; + static final long CONFIGURE_WIDTH = 56; + static final long CONFIGURE_HEIGHT = 60; + + /** {@code XReparentEvent}: the reparented window and its new parent. */ + static final long REPARENT_WINDOW = 40; + static final long REPARENT_PARENT = 48; + + /** {@code XButtonEvent}. */ + static final long BUTTON_X_ROOT = 72; + static final long BUTTON_Y_ROOT = 76; + static final long BUTTON_BUTTON = 84; + + /** {@code XClientMessageEvent}. */ + static final long CLIENT_MESSAGE_TYPE = 40; + static final long CLIENT_MESSAGE_FORMAT = 48; + static final long CLIENT_MESSAGE_DATA = 56; + + /** {@code XSetWindowAttributes}. */ + static final long ATTRIBUTES_SIZE = 112; + static final long ATTRIBUTES_BACKGROUND_PIXEL = 8; + static final long ATTRIBUTES_BORDER_PIXEL = 24; + static final long ATTRIBUTES_EVENT_MASK = 72; + static final long ATTRIBUTES_COLORMAP = 96; + + /** {@code XSizeHints} fields we set, and its {@code flags} bits (Xutil.h). */ + static final long SIZE_HINTS_SIZE = 80; + static final long SIZE_HINTS_FLAGS = 0; + static final long SIZE_HINTS_MIN_WIDTH = 24; + static final long SIZE_HINTS_MIN_HEIGHT = 28; + static final long SIZE_HINTS_MAX_WIDTH = 32; + static final long SIZE_HINTS_MAX_HEIGHT = 36; + static final long SIZE_HINTS_BASE_WIDTH = 64; + static final long SIZE_HINTS_BASE_HEIGHT = 68; + static final long P_MIN_SIZE = 1L << 4; + static final long P_MAX_SIZE = 1L << 5; + static final long P_BASE_SIZE = 1L << 8; + + /** {@code XVisualInfo}. */ + static final long VISUAL_INFO_SIZE = 64; + static final long VISUAL_INFO_VISUAL = 0; + static final long VISUAL_INFO_VISUALID = 8; + static final long VISUAL_INFO_DEPTH = 20; + + /** {@code XImage}: only the data pointer is touched, to unhook it before freeing. */ + static final long IMAGE_DATA = 16; + + private static final Linker LINKER = Linker.nativeLinker(); + private static final String[] LIBRARY_NAMES = {"libX11.so.6", "libX11.so"}; + + private static final class Handles { + static final SymbolLookup LOOKUP = load(); + + static final MethodHandle X_OPEN_DISPLAY = + downcall("XOpenDisplay", FunctionDescriptor.of(PTR, PTR)); + static final MethodHandle X_CLOSE_DISPLAY = + downcall("XCloseDisplay", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_INTERN_ATOM = + downcall("XInternAtom", FunctionDescriptor.of(LONG, PTR, PTR, INT)); + static final MethodHandle X_GET_SELECTION_OWNER = + downcall("XGetSelectionOwner", FunctionDescriptor.of(LONG, PTR, LONG)); + static final MethodHandle X_DEFAULT_SCREEN = + downcall("XDefaultScreen", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_ROOT_WINDOW = + downcall("XRootWindow", FunctionDescriptor.of(LONG, PTR, INT)); + static final MethodHandle X_GET_WINDOW_PROPERTY = + downcall("XGetWindowProperty", FunctionDescriptor.of(INT, PTR, LONG, LONG, LONG, LONG, + INT, LONG, PTR, PTR, PTR, PTR, PTR)); + static final MethodHandle X_GET_VISUAL_INFO = + downcall("XGetVisualInfo", FunctionDescriptor.of(PTR, PTR, LONG, PTR, PTR)); + static final MethodHandle X_FREE = + downcall("XFree", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_CREATE_COLORMAP = + downcall("XCreateColormap", FunctionDescriptor.of(LONG, PTR, LONG, PTR, INT)); + static final MethodHandle X_CREATE_WINDOW = + downcall("XCreateWindow", FunctionDescriptor.of(LONG, PTR, LONG, INT, INT, INT, INT, + INT, INT, INT, PTR, LONG, PTR)); + static final MethodHandle X_DESTROY_WINDOW = + downcall("XDestroyWindow", FunctionDescriptor.of(INT, PTR, LONG)); + static final MethodHandle X_CHANGE_PROPERTY = + downcall("XChangeProperty", FunctionDescriptor.of(INT, PTR, LONG, LONG, LONG, INT, + INT, PTR, INT)); + static final MethodHandle X_SELECT_INPUT = + downcall("XSelectInput", FunctionDescriptor.of(INT, PTR, LONG, LONG)); + static final MethodHandle X_SEND_EVENT = + downcall("XSendEvent", FunctionDescriptor.of(INT, PTR, LONG, INT, LONG, PTR)); + static final MethodHandle X_SET_WM_NORMAL_HINTS = + downcall("XSetWMNormalHints", FunctionDescriptor.ofVoid(PTR, LONG, PTR)); + static final MethodHandle X_RESIZE_WINDOW = + downcall("XResizeWindow", FunctionDescriptor.of(INT, PTR, LONG, INT, INT)); + static final MethodHandle X_MAP_WINDOW = + downcall("XMapWindow", FunctionDescriptor.of(INT, PTR, LONG)); + static final MethodHandle X_UNMAP_WINDOW = + downcall("XUnmapWindow", FunctionDescriptor.of(INT, PTR, LONG)); + static final MethodHandle X_FLUSH = + downcall("XFlush", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_PENDING = + downcall("XPending", FunctionDescriptor.of(INT, PTR)); + static final MethodHandle X_NEXT_EVENT = + downcall("XNextEvent", FunctionDescriptor.of(INT, PTR, PTR)); + static final MethodHandle X_CREATE_GC = + downcall("XCreateGC", FunctionDescriptor.of(PTR, PTR, LONG, LONG, PTR)); + static final MethodHandle X_FREE_GC = + downcall("XFreeGC", FunctionDescriptor.of(INT, PTR, PTR)); + static final MethodHandle X_CREATE_IMAGE = + downcall("XCreateImage", FunctionDescriptor.of(PTR, PTR, PTR, INT, INT, INT, PTR, + INT, INT, INT, INT)); + static final MethodHandle X_PUT_IMAGE = + downcall("XPutImage", FunctionDescriptor.of(INT, PTR, LONG, PTR, PTR, INT, INT, INT, + INT, INT, INT)); + + private static SymbolLookup load() { + IllegalArgumentException last = null; + for (String name : LIBRARY_NAMES) { + try { + return SymbolLookup.libraryLookup(name, Arena.global()); + } catch (IllegalArgumentException e) { + last = e; + } + } + throw (last != null) ? last : new IllegalArgumentException("libX11 not found"); + } + + private static MethodHandle downcall(String symbol, FunctionDescriptor descriptor) { + return LINKER.downcallHandle( + LOOKUP.find(symbol).orElseThrow(() -> + new UnsatisfiedLinkError("libX11: unresolved symbol " + symbol)), + descriptor); + } + } + + /** {@code true} when libX11 could be loaded and every symbol we need resolved. */ + static boolean isAvailable() { + try { + return Handles.LOOKUP != null; + } catch (Throwable t) { + return false; + } + } + + // ---- display ---- + + /** @return the display, or {@link MemorySegment#NULL} when it cannot be opened */ + static MemorySegment openDisplay() { + return (MemorySegment) call(Handles.X_OPEN_DISPLAY, MemorySegment.NULL); + } + + static void closeDisplay(MemorySegment display) { + call(Handles.X_CLOSE_DISPLAY, display); + } + + static int defaultScreen(MemorySegment display) { + return (int) call(Handles.X_DEFAULT_SCREEN, display); + } + + static long rootWindow(MemorySegment display, int screen) { + return (long) call(Handles.X_ROOT_WINDOW, display, screen); + } + + static long internAtom(MemorySegment display, Arena arena, String name) { + return (long) call(Handles.X_INTERN_ATOM, display, arena.allocateFrom(name), 0); + } + + static long selectionOwner(MemorySegment display, long atom) { + return (long) call(Handles.X_GET_SELECTION_OWNER, display, atom); + } + + static void flush(MemorySegment display) { + call(Handles.X_FLUSH, display); + } + + // ---- properties ---- + + /** + * Reads a single 32-bit property value (which Xlib hands back as a C {@code long}). + * + * @return the value, or {@code 0} when the window has no such property + */ + static long cardinalProperty(MemorySegment display, Arena arena, long window, long property, + long type) { + 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, 1L, 0, + type, actualType, actualFormat, items, bytesAfter, data); + MemorySegment values = data.get(PTR, 0); + if (status != SUCCESS || values.equals(MemorySegment.NULL)) return 0; + long value = items.get(LONG, 0) > 0 + ? values.reinterpret(Long.BYTES).get(LONG, 0) + : 0; + call(Handles.X_FREE, values); + return value; + } + + static void setCardinals(MemorySegment display, Arena arena, long window, long property, + long type, long... values) { + MemorySegment data = arena.allocate(LONG, values.length); + for (int i = 0; i < values.length; i++) data.setAtIndex(LONG, i, values[i]); + call(Handles.X_CHANGE_PROPERTY, display, window, property, type, 32, PROP_MODE_REPLACE, + data, values.length); + } + + static void setText(MemorySegment display, Arena arena, long window, long property, long type, + String text) { + byte[] bytes = text.getBytes(StandardCharsets.UTF_8); + MemorySegment data = arena.allocate(bytes.length + 1); + MemorySegment.copy(bytes, 0, data, ValueLayout.JAVA_BYTE, 0, bytes.length); + call(Handles.X_CHANGE_PROPERTY, display, window, property, type, 8, PROP_MODE_REPLACE, + data, bytes.length); + } + + // ---- visuals ---- + + /** + * Looks a visual up by id. + * + * @return its {@code XVisualInfo} (owned by Xlib, to be released with {@link #free}) + * or {@link MemorySegment#NULL} when the id is unknown + */ + static MemorySegment visualInfo(MemorySegment display, Arena arena, long visualId) { + MemorySegment template = arena.allocate(VISUAL_INFO_SIZE); + template.set(LONG, VISUAL_INFO_VISUALID, visualId); + MemorySegment count = arena.allocate(INT); + MemorySegment info = (MemorySegment) call(Handles.X_GET_VISUAL_INFO, display, + VISUAL_ID_MASK, template, count); + if (info.equals(MemorySegment.NULL) || count.get(INT, 0) < 1) return MemorySegment.NULL; + return info.reinterpret(VISUAL_INFO_SIZE); + } + + static void free(MemorySegment pointer) { + call(Handles.X_FREE, pointer); + } + + // ---- windows ---- + + static long createColormap(MemorySegment display, long window, MemorySegment visual) { + return (long) call(Handles.X_CREATE_COLORMAP, display, window, visual, ALLOC_NONE); + } + + static long createWindow(MemorySegment display, long parent, int width, int height, int depth, + MemorySegment visual, MemorySegment attributes, long valueMask) { + return (long) call(Handles.X_CREATE_WINDOW, display, parent, 0, 0, width, height, 0, depth, + INPUT_OUTPUT, visual, valueMask, attributes); + } + + static void destroyWindow(MemorySegment display, long window) { + call(Handles.X_DESTROY_WINDOW, display, window); + } + + static void selectInput(MemorySegment display, long window, long mask) { + call(Handles.X_SELECT_INPUT, display, window, mask); + } + + /** + * Tells the panel how big the icon wants to be. XEmbed trays lay their children + * out from these hints; without them a panel is free to allocate a sliver. + */ + static void setSizeHints(MemorySegment display, Arena arena, long window, int size) { + MemorySegment hints = arena.allocate(SIZE_HINTS_SIZE); + hints.fill((byte) 0); + hints.set(LONG, SIZE_HINTS_FLAGS, P_MIN_SIZE | P_MAX_SIZE | P_BASE_SIZE); + hints.set(INT, SIZE_HINTS_MIN_WIDTH, size); + hints.set(INT, SIZE_HINTS_MIN_HEIGHT, size); + hints.set(INT, SIZE_HINTS_MAX_WIDTH, size); + hints.set(INT, SIZE_HINTS_MAX_HEIGHT, size); + hints.set(INT, SIZE_HINTS_BASE_WIDTH, size); + hints.set(INT, SIZE_HINTS_BASE_HEIGHT, size); + call(Handles.X_SET_WM_NORMAL_HINTS, display, window, hints); + } + + static void resizeWindow(MemorySegment display, long window, int width, int height) { + call(Handles.X_RESIZE_WINDOW, display, window, width, height); + } + + static void mapWindow(MemorySegment display, long window) { + call(Handles.X_MAP_WINDOW, display, window); + } + + static void unmapWindow(MemorySegment display, long window) { + call(Handles.X_UNMAP_WINDOW, display, window); + } + + // ---- events ---- + + static int pending(MemorySegment display) { + return (int) call(Handles.X_PENDING, display); + } + + static void nextEvent(MemorySegment display, MemorySegment event) { + call(Handles.X_NEXT_EVENT, display, event); + } + + static void sendEvent(MemorySegment display, long window, long mask, MemorySegment event) { + call(Handles.X_SEND_EVENT, display, window, 0, mask, event); + } + + // ---- drawing ---- + + static MemorySegment createGC(MemorySegment display, long drawable) { + return (MemorySegment) call(Handles.X_CREATE_GC, display, drawable, 0L, MemorySegment.NULL); + } + + static void freeGC(MemorySegment display, MemorySegment gc) { + call(Handles.X_FREE_GC, display, gc); + } + + /** Wraps a caller-owned pixel buffer in an {@code XImage} of 32-bit ARGB pixels. */ + static MemorySegment createImage(MemorySegment display, MemorySegment visual, int depth, + MemorySegment data, int width, int height) { + MemorySegment image = (MemorySegment) call(Handles.X_CREATE_IMAGE, display, visual, depth, + Z_PIXMAP, 0, data, width, height, 32, 0); + return image.equals(MemorySegment.NULL) ? image : image.reinterpret(IMAGE_DATA + 8); + } + + /** Frees the {@code XImage} without touching the pixel buffer, which is ours. */ + static void destroyImage(MemorySegment image) { + image.set(PTR, IMAGE_DATA, MemorySegment.NULL); + call(Handles.X_FREE, image); + } + + static void putImage(MemorySegment display, long drawable, MemorySegment gc, + MemorySegment image, int width, int height) { + call(Handles.X_PUT_IMAGE, display, drawable, gc, image, 0, 0, 0, 0, width, height); + } + + private static Object call(MethodHandle handle, Object... args) { + try { + return handle.invokeWithArguments(args); + } catch (Throwable t) { + throw new IllegalStateException("libX11 call failed", t); + } + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java new file mode 100644 index 0000000..f5eb716 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11Tray.java @@ -0,0 +1,436 @@ +package com.ts3client.ui.tray; + +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.awt.image.DataBufferInt; +import java.lang.foreign.Arena; +import java.lang.foreign.MemorySegment; +import java.lang.foreign.ValueLayout; +import java.util.function.IntFunction; + +/** + * A tray icon docked into the panel's notification area by hand, over the + * freedesktop system tray protocol. + * + *

AWT can do this too, but its icon is never transparent on X11: the toolkit + * embeds an ordinary window of the screen's default (opaque) visual and fills it + * with a background colour before drawing, so every icon sits on a coloured box. + * Panels that support transparency — MATE's, Xfce's, GNOME's — advertise an ARGB + * visual in {@code _NET_SYSTEM_TRAY_VISUAL} and composite the icon themselves; + * all that is needed is to dock a window of that visual, which is what + * this class does. + * + *

Everything here runs on one thread of its own, which owns the display + * connection and pumps the X event queue; the rest of the client only posts new + * icons to it. + */ +public final class X11Tray implements TrayBackend { + + /** The size to ask for; the panel resizes us to whatever it wants. */ + private static final int DEFAULT_SIZE = 24; + private static final int POLL_MS = 40; + /** How long to give the panel to adopt the icon before falling back to AWT. */ + private static final int DOCK_TIMEOUT_MS = 1500; + /** Anything narrower than this is a sliver, not an icon the panel meant to give us. */ + private static final int MIN_SENSIBLE_SIZE = 8; + /** A tall panel should still not get a huge icon. */ + private static final int MAX_ICON_SIZE = 64; + /** + * Panels that composite the icon themselves need not send us a single expose, so + * the icon is also repainted at this interval — it costs one 24×24 image. + */ + private static final int REPAINT_MS = 2000; + + /** {@code _NET_SYSTEM_TRAY_OPCODE} message: dock this window. */ + private static final long SYSTEM_TRAY_REQUEST_DOCK = 0; + /** {@code _XEMBED_INFO} flag: the icon wants to be mapped. */ + private static final long XEMBED_MAPPED = 1; + + private final Listener listener; + /** Lives as long as the icon: the display connection and every buffer we pass to it. */ + private final Arena arena = Arena.ofShared(); + + private final MemorySegment display; + private final long root; + private final long window; + private final MemorySegment visual; + private final int depth; + private final MemorySegment gc; + + private final long selectionAtom; + private final long opcodeAtom; + private final long managerAtom; + + private final Thread thread; + private volatile boolean running = true; + /** Set once the panel has reparented the icon into itself. */ + private volatile boolean docked; + /** The square size last asked of the panel, so we ask only once per size. */ + private int requested; + + /** The icon and the size the panel gave us, both read by the event thread. */ + private volatile IntFunction render; + private volatile int width = DEFAULT_SIZE; + private volatile int height = DEFAULT_SIZE; + private volatile boolean dirty = true; + + /** The pixel buffer handed to X, kept until the icon's size changes. */ + private MemorySegment pixels; + private long lastDrawn; + private MemorySegment image; + private int imageWidth; + private int imageHeight; + + /** + * Docks an icon into the notification area. + * + * @return the icon, or {@code null} when this is not an X11 session, there is no + * notification area, or it cannot show transparent icons — the caller should then + * fall back to AWT's tray, which works everywhere but always draws a background + */ + public static X11Tray create(Listener listener, IntFunction initial) { + if (!X11.isAvailable()) { + TrayLog.debug("libX11 is not available"); + return null; + } + try { + X11Tray tray = new X11Tray(listener, initial); + TrayLog.debug("docked into the notification area, " + tray.width + "x" + tray.height + + " in an ARGB visual"); + return tray; + } catch (RuntimeException e) { + TrayLog.debug("not usable: " + e.getMessage()); + return null; + } + } + + private X11Tray(Listener listener, IntFunction initial) { + this.listener = listener; + this.render = initial; + + display = X11.openDisplay(); + if (display.equals(MemorySegment.NULL)) throw new Unsupported("no display"); + boolean ok = false; + try { + int screen = X11.defaultScreen(display); + root = X11.rootWindow(display, screen); + + selectionAtom = X11.internAtom(display, arena, "_NET_SYSTEM_TRAY_S" + screen); + opcodeAtom = X11.internAtom(display, arena, "_NET_SYSTEM_TRAY_OPCODE"); + managerAtom = X11.internAtom(display, arena, "MANAGER"); + + long manager = X11.selectionOwner(display, selectionAtom); + if (manager == 0) throw new Unsupported("no notification area"); + + visual = argbVisual(manager); + depth = 32; // argbVisual() accepts nothing else + window = createWindow(); + gc = X11.createGC(display, window); + + // Panels announce themselves with a MANAGER message when they (re)start, + // which is our cue to dock again. + X11.selectInput(display, root, X11.STRUCTURE_NOTIFY_MASK); + requestDock(manager); + // Docking is a request, not a call: a panel that ignores it would leave us + // with an invisible window and the user with no icon at all. + if (!awaitDock()) throw new Unsupported("the notification area did not adopt the icon"); + + thread = new Thread(this::pump, "x11-tray"); + thread.setDaemon(true); + thread.start(); + ok = true; + } finally { + if (!ok) { + X11.closeDisplay(display); + arena.close(); + } + } + } + + // ---- setup ---- + + /** + * @return the visual the panel wants transparent icons drawn in + * @throws Unsupported when it offers none, so nothing would be gained over AWT + */ + private MemorySegment argbVisual(long manager) { + long visualAtom = X11.internAtom(display, arena, "_NET_SYSTEM_TRAY_VISUAL"); + long visualId = X11.cardinalProperty(display, arena, manager, visualAtom, X11.XA_VISUALID); + if (visualId == 0) throw new Unsupported("the notification area is not transparent"); + + MemorySegment info = X11.visualInfo(display, arena, visualId); + if (info.equals(MemorySegment.NULL)) throw new Unsupported("unknown visual " + visualId); + try { + int visualDepth = info.get(ValueLayout.JAVA_INT, X11.VISUAL_INFO_DEPTH); + if (visualDepth != 32) { + throw new Unsupported("visual " + visualId + " has depth " + visualDepth); + } + // The XVisualInfo itself is Xlib's, but the Visual it points at outlives it. + return info.get(X11.PTR, X11.VISUAL_INFO_VISUAL); + } finally { + X11.free(info); + } + } + + private long createWindow() { + MemorySegment attributes = arena.allocate(X11.ATTRIBUTES_SIZE); + // Fully transparent background, and no border pixmap inherited from the root: + // a window whose depth differs from its parent's must name its own. + attributes.set(ValueLayout.JAVA_LONG, X11.ATTRIBUTES_BACKGROUND_PIXEL, 0); + attributes.set(ValueLayout.JAVA_LONG, X11.ATTRIBUTES_BORDER_PIXEL, 0); + attributes.set(ValueLayout.JAVA_LONG, X11.ATTRIBUTES_COLORMAP, + X11.createColormap(display, root, visual)); + attributes.set(ValueLayout.JAVA_LONG, X11.ATTRIBUTES_EVENT_MASK, + X11.EXPOSURE_MASK | X11.STRUCTURE_NOTIFY_MASK | X11.BUTTON_PRESS_MASK); + long mask = X11.CW_BACK_PIXEL | X11.CW_BORDER_PIXEL | X11.CW_COLORMAP | X11.CW_EVENT_MASK; + + long id = X11.createWindow(display, root, DEFAULT_SIZE, DEFAULT_SIZE, depth, visual, + attributes, mask); + if (id == 0) throw new Unsupported("could not create the icon window"); + + // Panels lay their icons out from these; without them MATE's allocates a + // one-pixel-wide sliver, which looks exactly like no icon at all. + X11.setSizeHints(display, arena, id, DEFAULT_SIZE); + + X11.setText(display, arena, id, X11.internAtom(display, arena, "WM_NAME"), + X11.XA_STRING, "TS3J"); + // XEMBED protocol version 0, and "please map me". + long xembedInfo = X11.internAtom(display, arena, "_XEMBED_INFO"); + X11.setCardinals(display, arena, id, xembedInfo, xembedInfo, 0, XEMBED_MAPPED); + return id; + } + + /** Asks the notification area to adopt our window. */ + private void requestDock(long manager) { + 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, manager); + event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_TYPE, opcodeAtom); + event.set(ValueLayout.JAVA_INT, X11.CLIENT_MESSAGE_FORMAT, 32); + event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA, 0); // CurrentTime + event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA + 8, SYSTEM_TRAY_REQUEST_DOCK); + event.set(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA + 16, window); + X11.sendEvent(display, manager, X11.NO_EVENT_MASK, event); + X11.flush(display); + } + + /** + * Waits for the panel to reparent the icon into itself. + * + * @return {@code false} when it did not within {@link #DOCK_TIMEOUT_MS} + */ + private boolean awaitDock() { + MemorySegment event = arena.allocate(X11.EVENT_SIZE); + long deadline = System.currentTimeMillis() + DOCK_TIMEOUT_MS; + while (!docked && System.currentTimeMillis() < deadline) { + while (!docked && X11.pending(display) > 0) { + X11.nextEvent(display, event); + handle(event); + } + if (docked) break; + try { + Thread.sleep(POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + return docked; + } + + // ---- TrayBackend ---- + + @Override + public void setIcon(IntFunction render) { + this.render = render; + dirty = true; + } + + @Override + public void setTooltip(String tooltip) { + // Panels take the tooltip from the icon window's name; those that don't show + // nothing, which is what AWT's own icon does on X11 anyway. + synchronized (this) { + if (!running) return; + X11.setText(display, arena, window, X11.internAtom(display, arena, "WM_NAME"), + X11.XA_STRING, tooltip); + X11.setText(display, arena, window, X11.internAtom(display, arena, "_NET_WM_NAME"), + X11.internAtom(display, arena, "UTF8_STRING"), tooltip); + X11.flush(display); + } + } + + @Override + public String description() { + return "docked into the notification area, " + width + "x" + height + ", transparent"; + } + + @Override + public void dispose() { + if (!running) return; + running = false; + try { + thread.join(500); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + synchronized (this) { + if (image != null) X11.destroyImage(image); + X11.freeGC(display, gc); + X11.destroyWindow(display, window); + X11.closeDisplay(display); + } + arena.close(); + } + + // ---- event thread ---- + + private void pump() { + MemorySegment event = arena.allocate(X11.EVENT_SIZE); + while (running) { + try { + synchronized (this) { + while (running && X11.pending(display) > 0) { + X11.nextEvent(display, event); + handle(event); + } + boolean due = System.currentTimeMillis() - lastDrawn >= REPAINT_MS; + if (running && (dirty || due)) { + dirty = false; + lastDrawn = System.currentTimeMillis(); + draw(); + } + } + Thread.sleep(POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (RuntimeException e) { + TrayLog.debug("the display connection failed: " + e.getMessage()); + return; // the display died with the panel or the session + } + } + } + + private void handle(MemorySegment event) { + switch (event.get(ValueLayout.JAVA_INT, X11.EVENT_TYPE)) { + case X11.EXPOSE -> dirty = true; + case X11.CONFIGURE_NOTIFY -> { + if (event.get(ValueLayout.JAVA_LONG, X11.CONFIGURE_WINDOW) != window) return; + int w = event.get(ValueLayout.JAVA_INT, X11.CONFIGURE_WIDTH); + int h = event.get(ValueLayout.JAVA_INT, X11.CONFIGURE_HEIGHT); + if (w == width && h == height) return; + width = w; + height = h; + dirty = true; + askForSquare(); + } + case X11.REPARENT_NOTIFY -> { + if (event.get(ValueLayout.JAVA_LONG, X11.REPARENT_WINDOW) != window) return; + if (event.get(ValueLayout.JAVA_LONG, X11.REPARENT_PARENT) == root) { + // The panel went away and handed the window back to the root; hide + // it so it does not turn up in the middle of the screen. + docked = false; + X11.unmapWindow(display, window); + } else { + // Adopted. The panel is meant to map us because of _XEMBED_INFO, + // but mapping ourselves costs nothing and does not depend on it. + docked = true; + X11.mapWindow(display, window); + dirty = true; + } + } + case X11.MAP_NOTIFY -> dirty = true; + case X11.BUTTON_PRESS -> { + long button = event.get(ValueLayout.JAVA_INT, X11.BUTTON_BUTTON); + int x = event.get(ValueLayout.JAVA_INT, X11.BUTTON_X_ROOT); + int y = event.get(ValueLayout.JAVA_INT, X11.BUTTON_Y_ROOT); + if (button == 1) { + listener.activated(); + } else if (button == 3) { + listener.menuRequested(x, y); + } + } + case X11.CLIENT_MESSAGE -> { + // A panel started: dock into it. + if (event.get(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_TYPE) != managerAtom) return; + if (event.get(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA + 8) != selectionAtom) { + return; + } + long manager = event.get(ValueLayout.JAVA_LONG, X11.CLIENT_MESSAGE_DATA + 16); + if (manager != 0) { + requestDock(manager); + X11.mapWindow(display, window); + dirty = true; + } + } + default -> { + } + } + } + + /** + * Asks for a square icon when the panel has given us a sliver — its idea of the + * icon size is then the other side, and a panel that lays out from the size hints + * grants it. Each size is asked for once, so a panel that refuses cannot put us + * in a loop. + */ + private void askForSquare() { + int narrow = Math.min(width, height); + int wide = Math.max(width, height); + // Only a sliver is worth arguing about; any sane allocation is left alone. + if (narrow >= MIN_SENSIBLE_SIZE) return; + int wanted = Math.min(wide, MAX_ICON_SIZE); + if (wanted <= 0 || wanted == requested) return; + requested = wanted; + X11.setSizeHints(display, arena, window, wanted); + X11.resizeWindow(display, window, wanted, wanted); + X11.flush(display); + } + + /** Paints the current icon over the whole window, alpha and all. */ + private void draw() { + IntFunction painter = render; + if (painter == null) return; + int w = width; + int h = height; + if (w <= 0 || h <= 0) return; + + if (image == null || imageWidth != w || imageHeight != h) { + if (image != null) X11.destroyImage(image); + pixels = arena.allocate((long) w * h * Integer.BYTES); + image = X11.createImage(display, visual, depth, pixels, w, h); + imageWidth = w; + imageHeight = h; + if (image.equals(MemorySegment.NULL)) { + image = null; + return; + } + } + + // X wants the pixels premultiplied, in the server's own byte order — which + // for a 32-bit ARGB visual on a local display is an int per pixel. + BufferedImage buffer = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE); + Graphics2D g = buffer.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR); + Image icon = painter.apply(Math.min(w, h)); + if (icon != null) g.drawImage(icon, 0, 0, w, h, null); + g.dispose(); + + int[] data = ((DataBufferInt) buffer.getRaster().getDataBuffer()).getData(); + MemorySegment.copy(data, 0, pixels, ValueLayout.JAVA_INT, 0, data.length); + X11.putImage(display, window, gc, image, w, h); + X11.flush(display); + } + + /** Thrown while setting up when this desktop cannot do what we need. */ + private static final class Unsupported extends RuntimeException { + Unsupported(String message) { + super(message); + } + } +}