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; } } }