Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/Icons.java
ericek111 35f10ed14c Paint the client with FlatLaf, in a light and a dark theme
The look-and-feel is FlatLaf now, chosen light or dark on the new Design
tab and remembered in the settings. Theme keeps only the colours TeamSpeak
gives meaning to (talking, away, muted, channel and server names) and takes
its surfaces and fonts from the look-and-feel, so a switch recolours the
whole window, chat logs included: the HTML panes share one themed stylesheet
and re-parse their content when the theme changes.

Also fixes the channel tree not scrolling: the tree listened for the mouse
wheel to re-check its hover row, and AWT only forwards a wheel event to the
enclosing scroll pane when the component under the pointer has no wheel
listener of its own. The hover row now follows the viewport instead, which
also covers scrollbar drags and keyboard scrolling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 21:06:55 +00:00

462 lines
16 KiB
Java

package com.ts3client.ui;
import javax.swing.ImageIcon;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
/**
* The user interface's icons, taken from the active {@link IconTheme} pack and
* named by TeamSpeak's icon keys.
*
* <p>The icons the client cannot do without are also drawn programmatically here,
* so a machine with no icon pack installed still gets a complete (if plain) set.
* Everything else — menu and dialog decoration — simply stays blank without a pack,
* which is why {@link #of(String)} may return {@code null}.
*/
public final class Icons {
private static final int SZ = IconTheme.SIZE;
private Icons() {
}
private interface Painter {
void paint(Graphics2D g);
}
/** @return the pack's icon for a TeamSpeak icon key, or {@code null} when it has none */
public static ImageIcon of(String key) {
return IconTheme.icon(key);
}
/**
* @return the pack's icon for the first key it has, or {@code null} when it has none
* of them — packs differ in which variants they ship
*/
public static ImageIcon ofAny(String... keys) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key);
if (icon != null) return icon;
}
return null;
}
/** @return the pack's icon at a given size, or {@code null} when it has none */
public static ImageIcon of(String key, int size) {
return IconTheme.icon(key, size);
}
private static ImageIcon themed(String key, Painter fallback) {
return themed(key, SZ, fallback);
}
private static ImageIcon themed(String key, int size, Painter fallback) {
ImageIcon icon = IconTheme.icon(key, size);
return icon != null ? icon : make(size, fallback);
}
/**
* Like {@link #themed(String, Painter)}, but tries the keys in turn — packs need not
* ship every variant (none of TeamSpeak's own has a subscribed password channel).
*/
private static ImageIcon themed(String[] keys, Painter fallback) {
for (String key : keys) {
ImageIcon icon = IconTheme.icon(key, SZ);
if (icon != null) return icon;
}
return make(fallback);
}
private static ImageIcon make(Painter p) {
return make(SZ, p);
}
/** Runs a painter (which draws in a 16&times;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);
}
// ---- tree icons ----
public static ImageIcon server() {
return themed("SERVER_GREEN", Icons::paintServer);
}
public static ImageIcon channel(boolean subscribed) {
return themed(keys("CHANNEL_GREEN", subscribed),
g -> paintChannel(g, new Color(0x3E7CB1), false, subscribed));
}
/** A channel nobody can join without its password. */
public static ImageIcon channelLocked(boolean subscribed) {
return themed(keys("CHANNEL_PRIVATE", subscribed),
g -> paintChannel(g, new Color(0x8A6D3B), true, subscribed));
}
/** A channel that has reached its client limit. */
public static ImageIcon channelFull(boolean subscribed) {
return themed(keys("CHANNEL_RED", subscribed),
g -> paintChannel(g, new Color(0xA53F3F), false, subscribed));
}
/** The pack keys to try for a channel icon, most specific first. */
private static String[] keys(String base, boolean subscribed) {
return subscribed ? new String[]{base + "_SUBSCRIBED", base} : new String[]{base};
}
// ---- client status icons ----
public static ImageIcon clientIdle() {
return clientIdle(SZ);
}
public static ImageIcon clientIdle(int size) {
return themed("PLAYER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
}
public static ImageIcon clientTalking() {
return clientTalking(SZ);
}
public static ImageIcon clientTalking(int size) {
return themed("PLAYER_ON", size, g -> paintPerson(g, Theme.talking()));
}
public static ImageIcon clientAway() {
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 clientCommander(SZ);
}
/** A channel commander that is talking. */
public static ImageIcon clientCommanderTalking() {
return clientCommanderTalking(SZ);
}
public static ImageIcon clientQuery() {
return themed("SERVER_QUERY", g -> paintPerson(g, Theme.idleClient()));
}
public static ImageIcon micMuted() {
return micMuted(SZ);
}
public static ImageIcon micMuted(int size) {
return themed("INPUT_MUTED", size, Icons::paintMicMuted);
}
public static ImageIcon speakerMuted() {
return speakerMuted(SZ);
}
public static ImageIcon speakerMuted(int size) {
return themed("OUTPUT_MUTED", size, Icons::paintSpeakerMuted);
}
/** The capture device is unavailable (another tab holds it), as opposed to muted. */
public static ImageIcon micDisabled() {
return micDisabled(SZ);
}
public static ImageIcon micDisabled(int size) {
return themed("HARDWARE_INPUT_MUTED", size, Icons::paintMicDisabled);
}
/** The playback device is unavailable, as opposed to muted/deafened. */
public static ImageIcon speakerDisabled() {
return speakerDisabled(SZ);
}
public static ImageIcon speakerDisabled(int size) {
return themed("HARDWARE_OUTPUT_MUTED", size, Icons::paintSpeakerDisabled);
}
/** TS3's "Local Mic Mute": silenced, but not reported as muted. */
public static ImageIcon micLocalMuted() {
return micLocalMuted(SZ);
}
public static ImageIcon micLocalMuted(int size) {
return themed("INPUT_MUTED_LOCAL", size, Icons::paintMicLocalMuted);
}
/** A channel commander that is not talking. */
public static ImageIcon clientCommander(int size) {
return themed("PLAYER_COMMANDER_OFF", size, g -> paintPerson(g, Theme.idleClient()));
}
/** 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 ----
public static ImageIcon connect() {
return themed("CONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintConnect);
}
public static ImageIcon disconnect() {
return themed("DISCONNECT", IconTheme.TOOLBAR_SIZE, Icons::paintDisconnect);
}
public static ImageIcon mic() {
return themed("CAPTURE", IconTheme.TOOLBAR_SIZE, Icons::paintMic);
}
public static ImageIcon speaker() {
return themed("PLAYBACK", IconTheme.TOOLBAR_SIZE, Icons::paintSpeaker);
}
public static ImageIcon micMutedLarge() {
return themed("INPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintMicMuted);
}
public static ImageIcon speakerMutedLarge() {
return themed("OUTPUT_MUTED", IconTheme.TOOLBAR_SIZE, Icons::paintSpeakerMuted);
}
/** Marks the server that currently owns the microphone. */
public static ImageIcon micActive() {
return themed("ACTIVATE_MICROPHONE", IconTheme.TOOLBAR_SIZE, Icons::paintMicActive);
}
/** The same marker at tab-label size. */
public static ImageIcon micActiveSmall() {
return themed("ACTIVATE_MICROPHONE", Icons::paintMicActive);
}
/**
* A fixed-width "channel icon / edit icon" label icon, used where a tab must not
* change width as the object it shows changes (e.g. the info-panel-in-chat tab).
*/
public static ImageIcon channelClientPair() {
ImageIcon channel = channel(true);
ImageIcon edit = themed("EDIT", Icons::paintEdit);
int gap = 8;
int width = channel.getIconWidth() + gap + edit.getIconWidth();
int height = Math.max(channel.getIconHeight(), edit.getIconHeight());
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.drawImage(channel.getImage(), 0, (height - channel.getIconHeight()) / 2, null);
g.setColor(Theme.idleClient());
g.setFont(Theme.uiFont());
java.awt.FontMetrics fm = g.getFontMetrics();
int slashX = channel.getIconWidth() + (gap - fm.stringWidth("/")) / 2;
g.drawString("/", slashX, (height + fm.getAscent()) / 2 - 1);
g.drawImage(edit.getImage(), channel.getIconWidth() + gap,
(height - edit.getIconHeight()) / 2, null);
g.dispose();
return new ImageIcon(img);
}
/** The toolbar's away marker. */
public static ImageIcon away() {
return themed("AWAY", IconTheme.TOOLBAR_SIZE, g -> paintPerson(g, Theme.away()));
}
public static ImageIcon settings() {
return themed("SETTINGS", IconTheme.TOOLBAR_SIZE, Icons::paintSettings);
}
public static ImageIcon app() {
return app(SZ);
}
public static ImageIcon app(int size) {
return make(size, Icons::paintApp);
}
// ---- built-in painters ----
private static void paintServer(Graphics2D g) {
g.setColor(new Color(0x2C6EA5));
g.fillRoundRect(2, 3, 12, 4, 2, 2);
g.fillRoundRect(2, 9, 12, 4, 2, 2);
g.setColor(new Color(0x9FD0F0));
g.fillOval(4, 4, 2, 2);
g.fillOval(4, 10, 2, 2);
}
/**
* The speaker-cone glyph. An unsubscribed channel is drawn hollow and without the
* sound waves, so the two states stay apart even without an icon pack.
*/
private static void paintChannel(Graphics2D g, Color c, boolean lock, boolean subscribed) {
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.setColor(c);
g.setStroke(new BasicStroke(subscribed ? 1.6f : 1.2f));
if (subscribed) {
g.fillRect(2, 6, 3, 4);
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
} else {
g.drawRect(2, 6, 3, 4);
g.drawPolygon(xs, ys, 4);
}
if (lock) {
g.setColor(new Color(0xB8860B));
g.fillRoundRect(10, 9, 5, 5, 1, 1);
g.setColor(Color.WHITE);
g.fillRect(12, 10, 1, 2);
}
}
private static void paintPerson(Graphics2D g, Color c) {
g.setColor(c);
g.fillOval(5, 2, 6, 6); // head
g.fillRoundRect(3, 9, 10, 6, 4, 4); // shoulders
}
private static void paintMicMuted(Graphics2D g) {
g.setColor(Theme.muted());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14); // slash
}
private static void paintSpeakerMuted(Graphics2D g) {
g.setColor(Theme.muted());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** Same shape as {@link #paintMicMuted}, but grey: unavailable, not muted by choice. */
private static void paintMicDisabled(Graphics2D g) {
g.setColor(Theme.idleClient());
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** Same shape as {@link #paintSpeakerMuted}, but grey: unavailable, not muted by choice. */
private static void paintSpeakerDisabled(Graphics2D g) {
g.setColor(Theme.idleClient());
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(2f));
g.drawLine(2, 2, 14, 14);
}
/** {@link #paintMicMuted}, marked with a small dot: silenced, but not reported as muted. */
private static void paintMicLocalMuted(Graphics2D g) {
paintMicMuted(g);
g.setColor(Theme.accent());
g.fillOval(11, 10, 4, 4);
}
private static void paintConnect(Graphics2D g) {
g.setColor(new Color(0x2E8B57));
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(10, 3, 10, 5);
g.drawLine(12, 3, 12, 5);
}
private static void paintDisconnect(Graphics2D g) {
g.setColor(Theme.muted());
g.setStroke(new BasicStroke(2f));
g.drawLine(3, 8, 8, 8);
g.fillRoundRect(8, 5, 5, 6, 2, 2);
g.drawLine(2, 3, 6, 13);
}
private static void paintMic(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.fillRoundRect(6, 2, 4, 7, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 12, 8, 14);
g.drawLine(6, 14, 10, 14);
}
private static void paintSpeaker(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.fillRect(2, 6, 3, 4);
int[] xs = {5, 9, 9, 5};
int[] ys = {6, 3, 13, 10};
g.fillPolygon(xs, ys, 4);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(9, 5, 4, 6, -60, 120);
}
private static void paintMicActive(Graphics2D g) {
g.setColor(Theme.talking());
g.fillOval(1, 1, 14, 14);
g.setColor(Color.WHITE);
g.fillRoundRect(6, 3, 4, 6, 2, 2);
g.setStroke(new BasicStroke(1.4f));
g.drawArc(4, 6, 8, 6, 200, 140);
g.drawLine(8, 11, 8, 13);
}
/** A pencil glyph, drawn when no icon pack ships an "EDIT" icon. */
private static void paintEdit(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(1.4f));
g.drawLine(3, 13, 11, 5);
g.drawLine(3, 13, 4, 10);
g.drawLine(4, 10, 11, 3);
g.drawLine(11, 3, 13, 5);
g.drawLine(13, 5, 11, 7);
g.drawLine(11, 5, 13, 7);
}
private static void paintSettings(Graphics2D g) {
g.setColor(new Color(0x37474F));
g.setStroke(new BasicStroke(2f));
g.drawOval(5, 5, 6, 6);
for (int a = 0; a < 360; a += 45) {
double r = Math.toRadians(a);
int x1 = (int) (8 + Math.cos(r) * 5);
int y1 = (int) (8 + Math.sin(r) * 5);
int x2 = (int) (8 + Math.cos(r) * 7);
int y2 = (int) (8 + Math.sin(r) * 7);
g.drawLine(x1, y1, x2, y2);
}
}
private static void paintApp(Graphics2D g) {
g.setColor(Theme.accent());
g.fillRoundRect(1, 1, 14, 14, 4, 4);
g.setColor(Color.WHITE);
g.setStroke(new BasicStroke(1.6f));
g.drawArc(4, 5, 8, 8, 30, 120);
g.drawArc(2, 3, 12, 12, 30, 120);
g.fillOval(7, 9, 2, 2);
}
}