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}. * *

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, 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 cache = new ConcurrentHashMap<>(); private final List listeners = new CopyOnWriteArrayList<>(); private volatile List 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 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×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; } } }