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>
This commit is contained in:
2026-08-14 12:46:39 +00:00
parent e1f62ab50d
commit 676e95023e
21 changed files with 1268 additions and 195 deletions

View File

@@ -0,0 +1,127 @@
package com.ts3client.gfx;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class IconPackTest {
private static final String INI = """
[info]
name = Test Icons
[options]
FALLBACK = false
[gfxfiles]
# a comment
CONNECT = connect.svg
DISCONNECT = Disconnect.SVG
CHANNEL_GREEN= channel_green.png
MISSING = not_shipped.svg
EMPTY =
""";
private static File zip(Path dir, String name, Map<String, String> entries) throws Exception {
File file = dir.resolve(name).toFile();
try (OutputStream out = Files.newOutputStream(file.toPath());
ZipOutputStream zip = new ZipOutputStream(out)) {
for (Map.Entry<String, String> e : entries.entrySet()) {
zip.putNextEntry(new ZipEntry(e.getKey()));
zip.write(e.getValue().getBytes(StandardCharsets.UTF_8));
zip.closeEntry();
}
}
return file;
}
@Test
void readsTheMappingOfAModernPack(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "test_pack.zip", Map.of(
"settings.ini", INI,
"connect.svg", "<svg/>",
"disconnect.svg", "<svg>disconnect</svg>",
"channel_green.png", "png bytes",
"emoticons/smile.svg", "<svg/>")));
assertNotNull(pack);
assertEquals("test_pack.zip", pack.id());
assertEquals("Test Icons", pack.name());
assertFalse(pack.fallsBack());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertTrue(pack.isVector("CONNECT", 16));
assertFalse(pack.isVector("CHANNEL_GREEN", 16));
// Keys are case-insensitive, as are the file names pack authors write.
assertArrayEquals("<svg>disconnect</svg>".getBytes(StandardCharsets.UTF_8),
pack.icon("disconnect", 16));
assertNull(pack.icon("MISSING", 16), "mapped to a file the pack does not ship");
assertNull(pack.icon("EMPTY", 16));
assertNull(pack.icon("NOT_IN_THE_PACK", 16));
}
@Test
void fallsBackByDefault(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "p.zip", Map.of(
"settings.ini", "[gfxfiles]\nCONNECT = connect.svg\n",
"connect.svg", "<svg/>")));
assertNotNull(pack);
assertTrue(pack.fallsBack());
}
@Test
void resolvesLegacyPacksByFileName(@TempDir Path dir) throws Exception {
IconPack pack = IconPack.load(zip(dir, "default.zip", Map.of(
"16x16_connect.png", "small",
"32x32_connect.png", "large",
"24x24_upload_avatar.png", "avatar",
"group_100.png", "group",
"emoticons/smile.png", "ignored")));
assertNotNull(pack);
assertEquals("Default", pack.name());
// The smallest image that still covers the requested size wins.
assertArrayEquals("small".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 32));
assertArrayEquals("large".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 64));
assertArrayEquals("avatar".getBytes(StandardCharsets.UTF_8), pack.icon("UPLOAD_AVATAR", 16));
assertArrayEquals("group".getBytes(StandardCharsets.UTF_8), pack.icon("GROUP_100", 16));
assertFalse(pack.has("SMILE"), "emoticons are not icons of the user interface");
}
@Test
void readsUnpackedDirectories(@TempDir Path dir) throws Exception {
Path unpacked = Files.createDirectory(dir.resolve("my_icons"));
Files.writeString(unpacked.resolve("settings.ini"), "[gfxfiles]\nCONNECT = connect.svg\n");
Files.writeString(unpacked.resolve("connect.svg"), "<svg/>");
IconPack pack = IconPack.load(unpacked.toFile());
assertNotNull(pack);
assertEquals("My Icons", pack.name());
assertArrayEquals("<svg/>".getBytes(StandardCharsets.UTF_8), pack.icon("CONNECT", 16));
}
@Test
void ignoresWhatIsNotAPack(@TempDir Path dir) throws Exception {
assertNull(IconPack.load(dir.resolve("nope.zip").toFile()));
assertNull(IconPack.load(Files.createDirectory(dir.resolve("empty")).toFile()));
assertNull(IconPack.load(null));
}
}