Show the local client's state in the system tray
The icon carries the badge the tree draws next to our own nickname on the active server — idle, talking, away, commander, microphone or speakers muted — and clicking it brings the window back to the front. Right click offers "Show TS3J" and "Quit". AWT's tray icon cannot be transparent on X11: the toolkit embeds a window of the screen's default, opaque visual and fills it with a background colour before drawing the image, so every icon sits in a box. Panels that can show transparent icons advertise an ARGB visual instead, so the icon is docked by hand over the system tray protocol, in that visual, with the image put on the window premultiplied. AWT's own icon stays as the fallback for everything else. Two details a panel will not forgive: an icon that publishes no size hints is allocated a one-pixel sliver, and docking is a request, so an icon that is never adopted has to hand over to the fallback rather than sit invisible. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ----
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Image> 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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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<Image> 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<Image> 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<Image> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<Image> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.ts3client.ui.tray;
|
||||
|
||||
/**
|
||||
* Diagnostics for the tray icon, off unless {@code -Dts3j.tray.debug=true} is given.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
411
ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java
Normal file
411
ts3-client/swing/src/main/java/com/ts3client/ui/tray/X11.java
Normal file
@@ -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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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 <em>that</em> visual, which is what
|
||||
* this class does.
|
||||
*
|
||||
* <p>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<Image> 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<Image> 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<Image> 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<Image> 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<Image> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user