Files
ts3j/ts3-client/swing/src/main/java/com/ts3client/ui/IconTheme.java
ericek111 676e95023e Draw the interface with TeamSpeak icon packs
Icon packs are read in TeamSpeak's own format — a zip (or unpacked folder)
of SVG/PNG art plus a settings.ini mapping icon keys to files — so the packs
of an installed client and the ones from its add-on site work unchanged.
Legacy packs without that mapping (default.zip) are resolved by their file
naming convention instead, picking the resolution closest to the drawn size.

The pack draws the tree, toolbar, menus, context menus, file browser and the
default group icons; a pack's FALLBACK option decides whether what it lacks
comes from default.zip, and anything still missing falls back to the icons
the client draws itself. Options → Design picks the pack and shows a viewer
of everything in it.

Vector art is rasterised with JSVG, and the pack search roots are shared
with the sound packs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:46:39 +00:00

168 lines
6.4 KiB
Java

package com.ts3client.ui;
import com.github.weisj.jsvg.SVGDocument;
import com.github.weisj.jsvg.parser.LoaderContext;
import com.github.weisj.jsvg.parser.SVGLoader;
import com.github.weisj.jsvg.view.ViewBox;
import com.ts3client.config.Settings;
import com.ts3client.gfx.IconPack;
import com.ts3client.gfx.IconPacks;
import javax.imageio.ImageIO;
import javax.swing.ImageIcon;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* The icons the user interface draws itself with, taken from the active
* {@link IconPack}.
*
* <p>Packs are TeamSpeak's, so an installed client's {@code gfx/} folder (and the
* packs from its add-on site) can be used as they are. A pack's own
* {@code FALLBACK} option decides whether icons it does not define are looked up
* in {@code default.zip}; whatever is still missing is drawn by {@link Icons}
* instead, so the client always has a complete set.
*
* <p>Rendering is cached per key and size: vector art is rasterised at the size it
* is asked for, bitmaps are scaled from the closest resolution the pack ships.
*/
public final class IconTheme {
/** The size of a tree row / menu item icon, as in the official client. */
public static final int SIZE = 16;
/** The size of the toolbar's buttons. */
public static final int TOOLBAR_SIZE = 24;
private static final IconTheme INSTANCE = new IconTheme();
private final Map<String, ImageIcon> cache = new ConcurrentHashMap<>();
private final List<Runnable> listeners = new CopyOnWriteArrayList<>();
private volatile List<IconPack> packs = List.of();
private volatile IconPack active;
private volatile IconPack fallback;
private IconTheme() {
}
public static IconTheme get() {
return INSTANCE;
}
/** The packs found on this machine, in display order. */
public List<IconPack> packs() {
return packs;
}
public IconPack activePack() {
return active;
}
/** Re-scans the pack folders and activates the one the settings name. */
public void reload(Settings settings) {
packs = IconPacks.findAll(new File(Settings.configDir(), "gfx"), settings.iconPackDir);
fallback = IconPacks.fallback(packs);
setPack(IconPacks.select(packs, settings.iconPack));
}
/** Switches to a pack (which may be {@code null} for the built-in icons). */
public void setPack(IconPack pack) {
if (active == pack) return;
active = pack;
cache.clear();
for (Runnable listener : listeners) listener.run();
}
/** Registers a repaint to run whenever the icons change. */
public void addListener(Runnable listener) {
listeners.add(listener);
}
/** @return the icon for a TeamSpeak icon key at 16&times;16, or {@code null} if no pack has it */
public static ImageIcon icon(String key) {
return icon(key, SIZE);
}
/** @return the icon for a TeamSpeak icon key, or {@code null} if no pack has it */
public static ImageIcon icon(String key, int size) {
return INSTANCE.lookup(key, size);
}
private ImageIcon lookup(String key, int size) {
if (key == null) return null;
String cacheKey = key + '@' + size;
ImageIcon cached = cache.get(cacheKey);
if (cached != null) return cached;
ImageIcon icon = render(active, key, size);
if (icon == null && active != null && active.fallsBack() && fallback != active) {
icon = render(fallback, key, size);
}
if (icon != null) cache.put(cacheKey, icon);
return icon;
}
private static ImageIcon render(IconPack pack, String key, int size) {
if (pack == null) return null;
byte[] data = pack.icon(key, size);
if (data == null || data.length == 0) return null;
BufferedImage image = pack.isVector(key, size) ? rasterize(data, size) : decode(data, size);
return image == null ? null : new ImageIcon(image);
}
private static BufferedImage rasterize(byte[] data, int size) {
try {
SVGDocument document = new SVGLoader().load(
new ByteArrayInputStream(data), null, LoaderContext.createDefault());
if (document == null) return null;
BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = image.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(RenderingHints.KEY_STROKE_CONTROL, RenderingHints.VALUE_STROKE_PURE);
document.render(null, g, new ViewBox(size, size));
} finally {
g.dispose();
}
return image;
} catch (Exception e) {
return null;
}
}
private static BufferedImage decode(byte[] data, int size) {
try {
BufferedImage source = ImageIO.read(new ByteArrayInputStream(data));
if (source == null) return null;
if (source.getWidth() == size && source.getHeight() == size) return source;
BufferedImage scaled = new BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = scaled.createGraphics();
try {
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
// Keep the aspect ratio of packs whose art is not square.
double factor = Math.min((double) size / source.getWidth(), (double) size / source.getHeight());
int w = Math.max(1, (int) Math.round(source.getWidth() * factor));
int h = Math.max(1, (int) Math.round(source.getHeight() * factor));
g.drawImage(source.getScaledInstance(w, h, Image.SCALE_SMOOTH),
(size - w) / 2, (size - h) / 2, null);
} finally {
g.dispose();
}
return scaled;
} catch (Exception e) {
return null;
}
}
}