From 5db3f4d8c47a3238c7fc9bafc7336068c87d4dc2 Mon Sep 17 00:00:00 2001 From: ericek111 Date: Thu, 13 Aug 2026 15:02:13 +0000 Subject: [PATCH] Two-column bookmark manager with connect-on-startup Replace the modal add/edit form with a master/detail dialog: bookmark list on the left, the selected bookmark's settings on the right. Edits are flushed back into the bookmark when the selection changes or the dialog closes. Bookmarks gain a "connect on startup" flag (only one at a time, since the client holds a single connection); MainFrame connects to it once the window is up. Co-Authored-By: Claude Opus 5 --- .../java/com/ts3client/config/Bookmark.java | 2 + .../java/com/ts3client/config/Bookmarks.java | 10 + .../com/ts3client/ui/BookmarksDialog.java | 315 ++++++++++++------ .../com/ts3client/ui/IdentityChooser.java | 22 +- .../main/java/com/ts3client/ui/MainFrame.java | 6 + 5 files changed, 242 insertions(+), 113 deletions(-) diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java index 07eb56a..cceef19 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmark.java @@ -16,6 +16,8 @@ public final class Bookmark { public String channel = ""; /** Password for {@link #channel}, if it is protected. */ public String channelPassword = ""; + /** Connect to this server automatically when the client starts. */ + public boolean connectOnStartup = false; public Bookmark() { } diff --git a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java index 433f7e2..f7740a6 100644 --- a/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java +++ b/ts3-client/core/src/main/java/com/ts3client/config/Bookmarks.java @@ -30,6 +30,14 @@ public final class Bookmarks { if (index >= 0 && index < entries.size()) entries.remove(index); } + /** First bookmark flagged to connect at startup, or {@code null}. */ + public Bookmark startupBookmark() { + for (Bookmark b : entries) { + if (b.connectOnStartup) return b; + } + return null; + } + public static Bookmarks load() { Bookmarks b = new Bookmarks(); if (!FILE.isFile()) return b; @@ -51,6 +59,7 @@ public final class Bookmarks { bm.identityId = p.getProperty(prefix + "identityId", ""); bm.channel = p.getProperty(prefix + "channel", ""); bm.channelPassword = p.getProperty(prefix + "channelPassword", ""); + bm.connectOnStartup = Boolean.parseBoolean(p.getProperty(prefix + "connectOnStartup", "false")); if (bm.address != null && !bm.address.isBlank()) b.entries.add(bm); } return b; @@ -70,6 +79,7 @@ public final class Bookmarks { p.setProperty(prefix + "identityId", nullToEmpty(bm.identityId)); p.setProperty(prefix + "channel", nullToEmpty(bm.channel)); p.setProperty(prefix + "channelPassword", nullToEmpty(bm.channelPassword)); + p.setProperty(prefix + "connectOnStartup", Boolean.toString(bm.connectOnStartup)); } try { if (!DIR.isDirectory()) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java index 4e73529..1598aac 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/BookmarksDialog.java @@ -9,6 +9,7 @@ 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; @@ -16,63 +17,238 @@ 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.GridLayout; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; import java.util.function.Consumer; -/** Manage saved servers: add, edit, remove and quick-connect. */ +/** + * 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 Consumer onConnect; private final Runnable onChanged; private final DefaultListModel listModel = new DefaultListModel<>(); private final JList 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, Consumer onConnect, Runnable onChanged) { super(owner, "Manage Bookmarks", true); this.bookmarks = bookmarks; - this.identities = identities; this.onConnect = onConnect; this.onChanged = onChanged; + this.identity = new IdentityChooser(identities, true, ""); - reload(); - list.setVisibleRowCount(10); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setVisibleRowCount(12); + list.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) onSelectionChanged(); + }); - JScrollPane scroll = new JScrollPane(list); - scroll.setBorder(BorderFactory.createEmptyBorder(8, 8, 8, 8)); + details = buildDetails(); - JPanel buttons = new JPanel(); - buttons.setLayout(new BoxLayout(buttons, BoxLayout.Y_AXIS)); - buttons.setBorder(BorderFactory.createEmptyBorder(8, 0, 8, 8)); - addButton(buttons, "Connect", this::connectSelected); - addButton(buttons, "Add…", this::addBookmark); - addButton(buttons, "Edit…", this::editSelected); - addButton(buttons, "Remove", this::removeSelected); - buttons.add(Box.createVerticalGlue()); - addButton(buttons, "Close", this::dispose); + JScrollPane listScroll = new JScrollPane(list); + listScroll.setPreferredSize(new Dimension(180, 320)); + + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, listScroll, details); + split.setBorder(BorderFactory.createEmptyBorder(8, 8, 4, 8)); + split.setResizeWeight(0.3); + split.setContinuousLayout(true); getContentPane().setLayout(new BorderLayout()); - getContentPane().add(scroll, BorderLayout.CENTER); - getContentPane().add(buttons, BorderLayout.EAST); + getContentPane().add(split, BorderLayout.CENTER); + getContentPane().add(buildButtons(), BorderLayout.SOUTH); + + reload(); + if (!listModel.isEmpty()) list.setSelectedIndex(0); + else showBookmark(null); setDefaultCloseOperation(DISPOSE_ON_CLOSE); - setSize(new Dimension(420, 300)); + addWindowListener(new java.awt.event.WindowAdapter() { + @Override + public void windowClosing(java.awt.event.WindowEvent e) { + commit(); + persist(); + } + }); + setSize(new Dimension(640, 400)); setLocationRelativeTo(owner); } + // ---- UI construction ---- + + private JPanel buildDetails() { + JPanel panel = new JPanel(new GridBagLayout()); + panel.setBorder(BorderFactory.createEmptyBorder(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."); + + 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 = 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(BorderFactory.createEmptyBorder(0, 8, 8, 8)); + addButton(buttons, "Add", this::addBookmark); + addButton(buttons, "Remove", this::removeSelected); + buttons.add(Box.createHorizontalGlue()); + addButton(buttons, "Connect", this::connectSelected); + addButton(buttons, "Close", this::closeDialog); + return buttons; + } + private void addButton(JPanel panel, String text, Runnable action) { JButton b = new JButton(text); - b.setAlignmentX(LEFT_ALIGNMENT); - b.setMaximumSize(new Dimension(Integer.MAX_VALUE, b.getPreferredSize().height)); b.addActionListener(e -> action.run()); panel.add(b); - panel.add(Box.createVerticalStrut(4)); + panel.add(Box.createHorizontalStrut(4)); + } + + // ---- 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(); + // Only one bookmark can be connected to at startup. + if (editing.connectOnStartup) { + for (Bookmark other : bookmarks.all()) { + if (other != editing) other.connectOnStartup = false; + } + } + 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 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() { @@ -80,94 +256,19 @@ public final class BookmarksDialog extends JDialog { for (Bookmark b : bookmarks.all()) listModel.addElement(b); } - private void connectSelected() { - Bookmark b = list.getSelectedValue(); - if (b != null) { - onConnect.accept(b); - dispose(); - } - } - - private void addBookmark() { - Bookmark b = new Bookmark("", "", 9987, System.getProperty("user.name", "TS3J User"), ""); - if (promptBookmark(b)) { - bookmarks.add(b); - persistAndRefresh(); - } - } - - private void editSelected() { - Bookmark b = list.getSelectedValue(); - if (b != null && promptBookmark(b)) { - persistAndRefresh(); - } - } - - private void removeSelected() { - int idx = list.getSelectedIndex(); - if (idx >= 0) { - bookmarks.remove(idx); - persistAndRefresh(); - } - } - - private void persistAndRefresh() { + private void persist() { bookmarks.save(); - reload(); if (onChanged != null) onChanged.run(); } - /** Modal add/edit form. Mutates {@code b} and returns whether the user confirmed. */ - private boolean promptBookmark(Bookmark b) { - JTextField label = new JTextField(b.label == null ? "" : b.label); - JTextField address = new JTextField(b.address == null ? "" : b.address); - JTextField port = new JTextField(Integer.toString(b.port)); - JTextField nick = new JTextField(b.nickname == null ? "" : b.nickname); - JPasswordField password = new JPasswordField(b.password == null ? "" : b.password); - JTextField channel = new JTextField(b.channel == null ? "" : b.channel); - channel.setToolTipText("Channel path, e.g. \"Lobby/Games\", or a channel id. " - + "Leave empty for the server's default channel."); - JPasswordField channelPassword = new JPasswordField(b.channelPassword == null ? "" : b.channelPassword); - IdentityChooser identity = new IdentityChooser(identities, true, b.identityId); - - JPanel form = new JPanel(new GridLayout(0, 1, 0, 2)); - form.add(new JLabel("Label:")); - form.add(label); - form.add(new JLabel("Address:")); - form.add(address); - form.add(new JLabel("Port:")); - form.add(port); - form.add(new JLabel("Nickname:")); - form.add(nick); - form.add(new JLabel("Password (optional):")); - form.add(password); - form.add(new JLabel("Default channel (path or id, optional):")); - form.add(channel); - form.add(new JLabel("Channel password (optional):")); - form.add(channelPassword); - form.add(new JLabel("Identity:")); - form.add(identity); - - int result = JOptionPane.showConfirmDialog(this, form, - "Bookmark", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); - if (result != JOptionPane.OK_OPTION) return false; - - if (address.getText().trim().isEmpty()) { - JOptionPane.showMessageDialog(this, "Address is required."); - return false; + 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); } - b.label = label.getText().trim(); - b.address = address.getText().trim(); - try { - b.port = Integer.parseInt(port.getText().trim()); - } catch (NumberFormatException e) { - b.port = 9987; - } - b.nickname = nick.getText().trim(); - b.password = new String(password.getPassword()); - b.channel = channel.getText().trim(); - b.channelPassword = new String(channelPassword.getPassword()); - b.identityId = identity.getSelectedIdentityId(); - return true; + } + + private static String nullToEmpty(String s) { + return s == null ? "" : s; } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java index 358da71..e1ca94c 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/IdentityChooser.java @@ -17,16 +17,16 @@ final class IdentityChooser extends JComboBox { private static final String USE_DEFAULT = "‹Default identity›"; + private final IdentityStore identities; + private final boolean allowDefault; + IdentityChooser(IdentityStore identities, boolean allowDefault, String selectedId) { + this.identities = identities; + this.allowDefault = allowDefault; if (allowDefault) addItem(USE_DEFAULT); for (IdentityEntry e : identities.all()) addItem(e); - IdentityEntry selected = identities.byId(selectedId); - if (selected != null) { - setSelectedItem(selected); - } else if (allowDefault) { - setSelectedItem(USE_DEFAULT); - } + setSelectedIdentityId(selectedId); setRenderer(new DefaultListCellRenderer() { @Override @@ -45,4 +45,14 @@ final class IdentityChooser extends JComboBox { Object v = getSelectedItem(); return v instanceof IdentityEntry ? ((IdentityEntry) v).getId() : ""; } + + /** Selects the identity with the given id, falling back to the default entry. */ + void setSelectedIdentityId(String id) { + IdentityEntry selected = identities.byId(id); + if (selected != null) { + setSelectedItem(selected); + } else if (allowDefault) { + setSelectedItem(USE_DEFAULT); + } + } } diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java index bb4f6f4..c15eb0b 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/MainFrame.java @@ -141,6 +141,12 @@ public final class MainFrame extends JFrame implements ConnectionListener, Serve statusTimer.start(); setSize(880, 560); setLocationRelativeTo(null); + + Bookmark startup = bookmarks.startupBookmark(); + if (startup != null) { + // Deferred so the window is on screen before we start connecting. + SwingUtilities.invokeLater(() -> connectToBookmark(startup)); + } } // ---- UI construction ----