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}.
*
*
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.
*
*
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 cache = new ConcurrentHashMap<>();
private final List listeners = new CopyOnWriteArrayList<>();
private volatile List 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 lookupOrder = List.of();
private IconTheme() {
}
public static IconTheme get() {
return INSTANCE;
}
/** The packs found on this machine, in display order. */
public List 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;
}
}
}