Files
ts3java/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java
ericek111 9d996f48a2 Scale the interface fractionally on Linux
Java2D on X11 (and so under XWayland) truncates its scale to a whole
number, drawing 150 % at 100 %. On Linux, pin Java2D to 1 and hand the
full factor to FlatLaf, taken from XSETTINGS Xft/DPI, KWin's Xwayland
scale or GDK_SCALE, or chosen in Options → Design.

The client's own sizes now go through UIScale: borders, gaps, dialog and
row sizes, icons (rendered at the scaled size, so they stay crisp),
custom-painted components and font sizes in the HTML views.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:33:27 +00:00

306 lines
12 KiB
Java

package com.ts3client.ui;
import com.formdev.flatlaf.util.ScaledEmptyBorder;
import com.formdev.flatlaf.util.UIScale;
import com.ts3client.config.Bookmark;
import com.ts3client.config.Bookmarks;
import com.ts3client.config.IdentityStore;
import com.ts3client.config.Settings;
import com.ts3client.teamspeak.TeamSpeakImporter;
import com.ts3client.teamspeak.TeamSpeakSettingsDb;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.DefaultListModel;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JList;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JPasswordField;
import javax.swing.JScrollPane;
import javax.swing.JSplitPane;
import javax.swing.JTextField;
import javax.swing.ListSelectionModel;
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.Frame;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.Insets;
import java.io.File;
import java.util.function.Consumer;
/**
* Manage saved servers. Two columns: the bookmark list on the left, the
* selected bookmark's settings on the right. Edits are applied to the selected
* bookmark as soon as the selection changes or the dialog is closed.
*/
public final class BookmarksDialog extends JDialog {
private final Bookmarks bookmarks;
private final IdentityStore identities;
private final Settings settings;
private final Consumer<Bookmark> onConnect;
private final Runnable onChanged;
private final DefaultListModel<Bookmark> listModel = new DefaultListModel<>();
private final JList<Bookmark> list = new JList<>(listModel);
private final JTextField label = new JTextField();
private final JTextField address = new JTextField();
private final JTextField port = new JTextField();
private final JTextField nick = new JTextField();
private final JPasswordField password = new JPasswordField();
private final JTextField channel = new JTextField();
private final JPasswordField channelPassword = new JPasswordField();
private final JCheckBox connectOnStartup = new JCheckBox("Connect on startup");
private final IdentityChooser identity;
private final JPanel details;
/** Bookmark the form currently shows, so edits can be flushed back into it. */
private Bookmark editing;
public BookmarksDialog(Frame owner, Bookmarks bookmarks, IdentityStore identities, Settings settings,
Consumer<Bookmark> onConnect, Runnable onChanged) {
super(owner, "Manage Bookmarks", true);
this.bookmarks = bookmarks;
this.identities = identities;
this.settings = settings;
this.onConnect = onConnect;
this.onChanged = onChanged;
this.identity = new IdentityChooser(identities, true, "");
list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
list.setVisibleRowCount(12);
list.addListSelectionListener(e -> {
if (!e.getValueIsAdjusting()) onSelectionChanged();
});
details = buildDetails();
JScrollPane listScroll = new JScrollPane(list);
listScroll.setPreferredSize(UIScale.scale(new Dimension(180, 320)));
JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, listScroll, details);
split.setBorder(new ScaledEmptyBorder(8, 8, 4, 8));
split.setResizeWeight(0.3);
split.setContinuousLayout(true);
getContentPane().setLayout(new BorderLayout());
getContentPane().add(split, BorderLayout.CENTER);
getContentPane().add(buildButtons(), BorderLayout.SOUTH);
reload();
if (!listModel.isEmpty()) list.setSelectedIndex(0);
else showBookmark(null);
Dialogs.closeOnEscape(this, this::closeDialog);
setDefaultCloseOperation(DISPOSE_ON_CLOSE);
addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent e) {
commit();
persist();
}
});
setSize(UIScale.scale(new Dimension(640, 400)));
setLocationRelativeTo(owner);
}
// ---- UI construction ----
private JPanel buildDetails() {
JPanel panel = new JPanel(new GridBagLayout());
panel.setBorder(new ScaledEmptyBorder(0, 8, 0, 0));
channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. "
+ "Leave empty for the server's default channel.");
connectOnStartup.setToolTipText("Connect to this server automatically when the client starts. "
+ "Several bookmarks may be flagged; each opens its own tab.");
int row = 0;
addRow(panel, row++, "Label:", label);
addRow(panel, row++, "Address:", address);
addRow(panel, row++, "Port:", port);
addRow(panel, row++, "Nickname:", nick);
addRow(panel, row++, "Password:", password);
addRow(panel, row++, "Default channel:", channel);
addRow(panel, row++, "Channel password:", channelPassword);
addRow(panel, row++, "Identity:", identity);
addRow(panel, row++, "", connectOnStartup);
GridBagConstraints filler = new GridBagConstraints();
filler.gridx = 0;
filler.gridy = row;
filler.weighty = 1;
filler.fill = GridBagConstraints.VERTICAL;
panel.add(Box.createVerticalGlue(), filler);
return panel;
}
private void addRow(JPanel panel, int row, String text, java.awt.Component field) {
GridBagConstraints c = new GridBagConstraints();
c.gridy = row;
c.insets = UIScale.scale(new Insets(2, 0, 2, 4));
c.anchor = GridBagConstraints.WEST;
c.gridx = 0;
panel.add(new JLabel(text), c);
c.gridx = 1;
c.weightx = 1;
c.fill = GridBagConstraints.HORIZONTAL;
panel.add(field, c);
}
private JPanel buildButtons() {
JPanel buttons = new JPanel();
buttons.setLayout(new BoxLayout(buttons, BoxLayout.X_AXIS));
buttons.setBorder(new ScaledEmptyBorder(0, 8, 8, 8));
addButton(buttons, "Add", this::addBookmark);
addButton(buttons, "Remove", this::removeSelected);
if (TeamSpeakSettingsDb.locate() != null) {
addButton(buttons, "Import from TeamSpeak 3…", this::importFromTeamSpeak)
.setToolTipText("Add the bookmarks of the TeamSpeak 3 client installed on this computer, "
+ "along with the identities they connect with");
}
buttons.add(Box.createHorizontalGlue());
addButton(buttons, "Connect", this::connectSelected);
addButton(buttons, "Close", this::closeDialog);
return buttons;
}
private JButton addButton(JPanel panel, String text, Runnable action) {
JButton b = new JButton(text);
b.addActionListener(e -> action.run());
panel.add(b);
panel.add(Box.createHorizontalStrut(UIScale.scale(4)));
return b;
}
// ---- Form <-> model ----
private void onSelectionChanged() {
commit();
showBookmark(list.getSelectedValue());
}
private void showBookmark(Bookmark b) {
editing = b;
setEnabledRecursively(details, b != null);
label.setText(b == null ? "" : nullToEmpty(b.label));
address.setText(b == null ? "" : nullToEmpty(b.address));
port.setText(b == null ? "" : Integer.toString(b.port));
nick.setText(b == null ? "" : nullToEmpty(b.nickname));
password.setText(b == null ? "" : nullToEmpty(b.password));
channel.setText(b == null ? "" : nullToEmpty(b.channel));
channelPassword.setText(b == null ? "" : nullToEmpty(b.channelPassword));
connectOnStartup.setSelected(b != null && b.connectOnStartup);
identity.setSelectedIdentityId(b == null ? "" : nullToEmpty(b.identityId));
}
/** Writes the form back into the bookmark it was loaded from. */
private void commit() {
if (editing == null) return;
editing.label = label.getText().trim();
editing.address = address.getText().trim();
try {
editing.port = Integer.parseInt(port.getText().trim());
} catch (NumberFormatException e) {
editing.port = 9987;
}
editing.nickname = nick.getText().trim();
editing.password = new String(password.getPassword());
editing.channel = channel.getText().trim();
editing.channelPassword = new String(channelPassword.getPassword());
editing.identityId = identity.getSelectedIdentityId();
editing.connectOnStartup = connectOnStartup.isSelected();
list.repaint();
}
// ---- Actions ----
private void addBookmark() {
commit();
Bookmark b = new Bookmark("New bookmark", "", 9987,
System.getProperty("user.name", "TS3J User"), "");
bookmarks.add(b);
reload();
list.setSelectedValue(b, true);
persist();
label.requestFocusInWindow();
}
private void removeSelected() {
int idx = list.getSelectedIndex();
if (idx < 0) return;
editing = null; // dropped, nothing to flush back
bookmarks.remove(idx);
reload();
if (!listModel.isEmpty()) list.setSelectedIndex(Math.min(idx, listModel.size() - 1));
else showBookmark(null);
persist();
}
private void importFromTeamSpeak() {
File db = TeamSpeakSettingsDb.locate();
if (db == null) return;
commit();
try {
TeamSpeakImporter.Result result = new TeamSpeakImporter(identities, bookmarks, settings).importBookmarks(db);
identity.reload();
reload();
if (result.bookmarksAdded() > 0) list.setSelectedIndex(listModel.size() - result.bookmarksAdded());
persist();
JOptionPane.showMessageDialog(this, result.bookmarks() + " bookmark(s) read from " + db
+ "\n" + result.bookmarksAdded() + " new, " + result.bookmarksKnown() + " already present; "
+ result.identitiesAdded() + " identit" + (result.identitiesAdded() == 1 ? "y" : "ies") + " imported.",
"Import from TeamSpeak 3", JOptionPane.INFORMATION_MESSAGE);
} catch (Exception e) {
JOptionPane.showMessageDialog(this, "Could not read " + db + ":\n" + e.getMessage(),
"Import from TeamSpeak 3", JOptionPane.ERROR_MESSAGE);
}
}
private void connectSelected() {
commit();
Bookmark b = list.getSelectedValue();
if (b == null) return;
if (b.address == null || b.address.isBlank()) {
JOptionPane.showMessageDialog(this, "Address is required.");
return;
}
persist();
onConnect.accept(b);
dispose();
}
private void closeDialog() {
commit();
persist();
dispose();
}
private void reload() {
listModel.clear();
for (Bookmark b : bookmarks.all()) listModel.addElement(b);
}
private void persist() {
bookmarks.save();
if (onChanged != null) onChanged.run();
}
private static void setEnabledRecursively(java.awt.Container container, boolean enabled) {
for (java.awt.Component c : container.getComponents()) {
c.setEnabled(enabled);
if (c instanceof java.awt.Container child) setEnabledRecursively(child, enabled);
}
}
private static String nullToEmpty(String s) {
return s == null ? "" : s;
}
}