The Android app's Material icons become a TeamSpeak-format pack shipped in core, which the desktop offers next to the TeamSpeak packs. Android now draws its icons from packs too and imports TeamSpeak's own zips, falling back to the Material pack for what they lack. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
191 lines
7.3 KiB
Java
191 lines
7.3 KiB
Java
package com.ts3client.ui;
|
|
|
|
import com.formdev.flatlaf.util.UIScale;
|
|
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.AppDirs;
|
|
import com.ts3client.config.Settings;
|
|
import com.ts3client.gfx.IconPack;
|
|
import com.ts3client.gfx.IconPacks;
|
|
|
|
import javax.imageio.ImageIO;
|
|
import javax.swing.ImageIcon;
|
|
import javax.swing.UIManager;
|
|
import java.awt.Color;
|
|
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, in the look-and-feel's text colour where it paints with
|
|
* {@code currentColor}; bitmaps are scaled from the closest resolution the pack ships.
|
|
* {@link #SIZE} and {@link #TOOLBAR_SIZE} are unscaled; sizes passed in are pixels.
|
|
*/
|
|
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;
|
|
/** Where icons are looked up; what none of these has is drawn by {@link Icons}. */
|
|
private volatile List<IconPack> lookupOrder = List.of();
|
|
|
|
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(AppDirs.profile(), "gfx"), settings.iconPackDir);
|
|
IconPack pack = IconPacks.select(packs, settings.iconPack);
|
|
// The shipped pack survives a re-scan as the same object, so setPack alone may not notice.
|
|
lookupOrder = IconPacks.lookupOrder(pack, packs, null);
|
|
setPack(pack);
|
|
}
|
|
|
|
/** 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;
|
|
lookupOrder = IconPacks.lookupOrder(pack, packs, null);
|
|
cache.clear();
|
|
for (Runnable listener : listeners) listener.run();
|
|
}
|
|
|
|
/** Re-renders the icons, which follow the look-and-feel's text colour. */
|
|
public void refresh() {
|
|
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 tree-row size, or {@code null} if no pack has it */
|
|
public static ImageIcon icon(String key) {
|
|
return icon(key, UIScale.scale(SIZE));
|
|
}
|
|
|
|
/** @return the icon for a TeamSpeak icon key at {@code size} pixels, 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 = null;
|
|
for (IconPack pack : lookupOrder) {
|
|
icon = render(pack, key, size);
|
|
if (icon != null) break;
|
|
}
|
|
if (icon != null) cache.put(cacheKey, icon);
|
|
return icon;
|
|
}
|
|
|
|
private static ImageIcon render(IconPack pack, String key, int size) {
|
|
byte[] data = pack.icon(key, size);
|
|
if (data == null || data.length == 0) return null;
|
|
BufferedImage image = pack.isVector(key, size)
|
|
? rasterize(IconPack.withCurrentColor(data, textColor().getRGB()), size)
|
|
: decode(data, size);
|
|
return image == null ? null : new ImageIcon(image);
|
|
}
|
|
|
|
private static Color textColor() {
|
|
Color c = UIManager.getColor("Label.foreground");
|
|
return c != null ? c : Color.BLACK;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|