Show clients' country flags beside their names, and optionally in the tree
The server places each client in a country (client_country, from its GeoIP database); a client's details now show that country's flag left of their name, as TeamSpeak's info frame does, on both clients. "Show country flags in the channel tree" (off by default, as TeamSpeak's EnableCountryFlags) also puts it last on client rows. The flags are TeamSpeak's countries.zip, which now ships with the icon packs; core's CountryFlags reads it, else a TeamSpeak 3 install's. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.gfx.CountryFlags;
|
||||
|
||||
import javax.swing.ImageIcon;
|
||||
import java.awt.Image;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** TeamSpeak's country flags as Swing icons, at their own 16×11 scaled with the interface. */
|
||||
final class CountryFlagIcons {
|
||||
|
||||
private static final int WIDTH = 16;
|
||||
private static final int HEIGHT = 11;
|
||||
private static final Map<String, Optional<ImageIcon>> ICONS = new ConcurrentHashMap<>();
|
||||
|
||||
private CountryFlagIcons() {
|
||||
}
|
||||
|
||||
/** @return the flag, or {@code null} for an unknown or empty code */
|
||||
static ImageIcon of(String code) {
|
||||
if (code == null || code.isEmpty()) return null;
|
||||
return ICONS.computeIfAbsent(code, CountryFlagIcons::load).orElse(null);
|
||||
}
|
||||
|
||||
private static Optional<ImageIcon> load(String code) {
|
||||
byte[] png = CountryFlags.png(code);
|
||||
if (png == null) return Optional.empty();
|
||||
ImageIcon icon = new ImageIcon(png);
|
||||
if (icon.getIconWidth() <= 0) return Optional.empty();
|
||||
int width = UIScale.scale(WIDTH);
|
||||
int height = UIScale.scale(HEIGHT);
|
||||
if (icon.getIconWidth() != width || icon.getIconHeight() != height) {
|
||||
icon = new ImageIcon(icon.getImage().getScaledInstance(width, height, Image.SCALE_SMOOTH));
|
||||
}
|
||||
return Optional.of(icon);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import javax.swing.DefaultListCellRenderer;
|
||||
import javax.swing.DefaultListModel;
|
||||
import javax.swing.ImageIcon;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JCheckBox;
|
||||
import javax.swing.JComboBox;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JFileChooser;
|
||||
@@ -59,6 +60,7 @@ final class DesignPanel extends JPanel {
|
||||
private final JComboBox<Object> packCombo = new JComboBox<>();
|
||||
private final JLabel packInfo = new JLabel();
|
||||
private final JTextField packDirField;
|
||||
private final JCheckBox countryFlags = new JCheckBox("Show country flags in the channel tree");
|
||||
private final DefaultListModel<String> previewModel = new DefaultListModel<>();
|
||||
private final JList<String> preview = new JList<>(previewModel);
|
||||
|
||||
@@ -84,6 +86,7 @@ final class DesignPanel extends JPanel {
|
||||
settings.iconPackDir = packDirField.getText().trim();
|
||||
Object selected = packCombo.getSelectedItem();
|
||||
settings.iconPack = selected instanceof IconPack pack ? pack.id() : "";
|
||||
settings.countryFlags = countryFlags.isSelected();
|
||||
}
|
||||
|
||||
/** Puts back the appearance and pack the dialog started with, for a cancelled edit. */
|
||||
@@ -149,7 +152,12 @@ final class DesignPanel extends JPanel {
|
||||
dirRow.add(packDirField, BorderLayout.CENTER);
|
||||
dirRow.add(browse, BorderLayout.EAST);
|
||||
packDirField.setToolTipText("Extra folder to look for icon packs in");
|
||||
addRow(p, c, row, new JLabel("Extra pack folder:"), dirRow);
|
||||
addRow(p, c, row++, new JLabel("Extra pack folder:"), dirRow);
|
||||
|
||||
countryFlags.setSelected(settings.countryFlags);
|
||||
c.gridx = 1;
|
||||
c.gridy = row;
|
||||
p.add(countryFlags, c);
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ts3client.ui;
|
||||
|
||||
import com.formdev.flatlaf.util.UIScale;
|
||||
import com.ts3client.config.Settings;
|
||||
import com.ts3client.net.ChannelNode;
|
||||
import com.ts3client.net.Spacers;
|
||||
import com.ts3client.net.ClientEntry;
|
||||
@@ -40,6 +41,7 @@ final class DropIndicatorTree extends JTree {
|
||||
|
||||
private final ServerModel model;
|
||||
private final GroupIcons groupIcons;
|
||||
private final Settings settings;
|
||||
/** Set while a drop would move a client into this channel row. */
|
||||
private TreePath highlight;
|
||||
/** The row the pointer is over, or -1 when it is over none. */
|
||||
@@ -48,10 +50,11 @@ final class DropIndicatorTree extends JTree {
|
||||
private Set<Object> searchHits = Collections.emptySet();
|
||||
private Predicate<TreePath> pathEditable = path -> false;
|
||||
|
||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) {
|
||||
DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons, Settings settings) {
|
||||
super(treeModel);
|
||||
this.model = model;
|
||||
this.groupIcons = groupIcons;
|
||||
this.settings = settings;
|
||||
MouseAdapter hover = new MouseAdapter() {
|
||||
@Override
|
||||
public void mouseMoved(MouseEvent e) {
|
||||
@@ -299,8 +302,12 @@ final class DropIndicatorTree extends JTree {
|
||||
private List<Icon> badgesOf(Object node) {
|
||||
if (node instanceof ClientEntry) {
|
||||
ClientEntry cl = (ClientEntry) node;
|
||||
return groupIcons.iconsOf(
|
||||
List<Icon> icons = groupIcons.iconsOf(
|
||||
model.serverGroupsOf(cl.serverGroupIds), model.channelGroup(cl.channelGroupId));
|
||||
// The country's flag comes last, at the row's edge, as in TeamSpeak.
|
||||
Icon flag = settings.countryFlags ? CountryFlagIcons.of(cl.country) : null;
|
||||
if (flag != null) icons.add(flag);
|
||||
return icons;
|
||||
}
|
||||
if (node instanceof ChannelNode) {
|
||||
ChannelNode ch = (ChannelNode) node;
|
||||
|
||||
@@ -11,8 +11,8 @@ import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Icons of the active pack as PNG files, for HTML views, which only show pictures by URL.
|
||||
* A pack change gives the theme new icons, and so new files.
|
||||
* Icons of the active pack, and country flags, as PNG files, for HTML views, which only
|
||||
* show pictures by URL. A pack change gives the theme new icons, and so new files.
|
||||
*/
|
||||
final class IconFiles {
|
||||
|
||||
@@ -31,6 +31,14 @@ final class IconFiles {
|
||||
return file.isFile() ? file : null;
|
||||
}
|
||||
|
||||
/** @return the country's flag as drawn in the tree, or {@code null} for none */
|
||||
static File flag(String code) {
|
||||
ImageIcon icon = CountryFlagIcons.of(code);
|
||||
if (icon == null) return null;
|
||||
File file = FILES.computeIfAbsent("flag_" + code + '_' + icon.getIconWidth() + ".png", n -> write(icon, n));
|
||||
return file.isFile() ? file : null;
|
||||
}
|
||||
|
||||
private static File write(ImageIcon icon, String name) {
|
||||
try {
|
||||
File file = new File(dir(), name);
|
||||
|
||||
@@ -241,8 +241,8 @@ public final class InfoPanel extends JPanel {
|
||||
|
||||
private String body() {
|
||||
showsPictures = false;
|
||||
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", null, null);
|
||||
if (shownChannel != null) return html(Details.channel(shownChannel), "", null, "No description.");
|
||||
if (shownServer != null) return html(Details.server(shownModel, shownServer), "", "", null, null);
|
||||
if (shownChannel != null) return html(Details.channel(shownChannel), "", "", null, "No description.");
|
||||
if (shownClient != null) {
|
||||
renderedAvatarBox = inChatTab ? UIScale.scale(AvatarHtml.MAX_SIZE) : avatarBox();
|
||||
return clientBody(shownClient, shownModel, shownIcons, shownContact, shownAvatar, renderedAvatarBox);
|
||||
@@ -278,17 +278,19 @@ public final class InfoPanel extends JPanel {
|
||||
|
||||
private String clientDetails(ClientEntry cl, ServerModel model, IconRepository icons, Contact contact) {
|
||||
String you = cl.self ? " <span class='muted'>(you)</span>" : "";
|
||||
return html(Details.client(cl, model, contact), you, icons, null);
|
||||
// The country's flag goes before the name, as in TeamSpeak's info frame.
|
||||
return html(Details.client(cl, model, contact), flagTag(cl.country), you, icons, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param titlePrefix HTML before the title
|
||||
* @param titleSuffix HTML after the title
|
||||
* @param icons for the icons of the groups a field names; null for none
|
||||
* @param none what to say when there is no description; null to say nothing
|
||||
*/
|
||||
private String html(Details details, String titleSuffix, IconRepository icons, String none) {
|
||||
private String html(Details details, String titlePrefix, String titleSuffix, IconRepository icons, String none) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(heading(esc(details.title()) + titleSuffix));
|
||||
sb.append(heading(titlePrefix + esc(details.title()) + titleSuffix));
|
||||
for (Details.Field f : details.fields()) row(sb, esc(f.label()), value(f, icons));
|
||||
if (!details.description().isEmpty()) {
|
||||
sb.append("<hr><div>").append(description(details.description())).append("</div>");
|
||||
@@ -327,6 +329,13 @@ public final class InfoPanel extends JPanel {
|
||||
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(14) + "' height='" + UIScale.scale(14) + "'> ";
|
||||
}
|
||||
|
||||
/** An inline {@code <img>} for a country's flag, or nothing for no country. */
|
||||
private static String flagTag(String country) {
|
||||
File file = IconFiles.flag(country);
|
||||
if (file == null) return "";
|
||||
return "<img src='" + file.toURI() + "' width='" + UIScale.scale(16) + "' height='" + UIScale.scale(11) + "'> ";
|
||||
}
|
||||
|
||||
private static String heading(String text) {
|
||||
return "<div style='font-weight:bold;font-size:13px;margin-bottom:" + HtmlStyles.px(4) + "'>" + text + "</div>";
|
||||
}
|
||||
|
||||
@@ -184,7 +184,7 @@ public final class ServerTreePanel extends JPanel {
|
||||
this.groupIcons = groupIcons;
|
||||
this.actions = actions;
|
||||
root.setUserObject("Not connected");
|
||||
this.tree = new DropIndicatorTree(treeModel, model, groupIcons);
|
||||
this.tree = new DropIndicatorTree(treeModel, model, groupIcons, settings);
|
||||
tree.setRootVisible(true);
|
||||
// The server node is the only top-level row and always stays open, so it gets
|
||||
// no expand control. Nesting is tightened too: horizontal space in this view
|
||||
|
||||
Reference in New Issue
Block a user