diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java index ae78043..b8839be 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/DropIndicatorTree.java @@ -19,6 +19,7 @@ import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseWheelEvent; import java.util.List; +import java.util.function.Predicate; /** * The {@link ServerTreePanel} tree, extended to draw where a drag-and-drop would @@ -40,6 +41,7 @@ final class DropIndicatorTree extends JTree { private TreePath highlight; /** The row the pointer is over, or -1 when it is over none. */ private int hoverRow = -1; + private Predicate pathEditable = path -> false; DropIndicatorTree(TreeModel treeModel, ServerModel model, GroupIcons groupIcons) { super(treeModel); @@ -106,6 +108,16 @@ final class DropIndicatorTree extends JTree { if (bounds != null) repaint(0, bounds.y, getWidth(), bounds.height); } + /** Which rows an inline editor may open on; nothing, until one is installed. */ + void setPathEditable(Predicate editable) { + this.pathEditable = editable; + } + + @Override + public boolean isPathEditable(TreePath path) { + return isEditable() && pathEditable.test(path); + } + /** Keeps the server row permanently open; collapsing it would hide everything. */ @Override public void setExpandedState(TreePath path, boolean state) { diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java b/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java new file mode 100644 index 0000000..66bca82 --- /dev/null +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/NicknameCellEditor.java @@ -0,0 +1,127 @@ +package com.ts3client.ui; + +import com.ts3client.net.ClientEntry; + +import javax.swing.DefaultCellEditor; +import javax.swing.JLabel; +import javax.swing.JTextField; +import javax.swing.JTree; +import javax.swing.SwingUtilities; +import javax.swing.Timer; +import javax.swing.tree.DefaultMutableTreeNode; +import javax.swing.tree.DefaultTreeCellEditor; +import javax.swing.tree.DefaultTreeCellRenderer; +import javax.swing.tree.TreePath; +import java.awt.Component; +import java.awt.event.MouseEvent; +import java.util.EventObject; +import java.util.function.IntPredicate; + +/** + * Turns our own row in the server tree into a text field on a double click, the way + * TeamSpeak renames a client in place. Only that one row is editable; every other + * double click keeps its usual meaning (joining a channel, opening a chat). + * + *

The field opens on the current nickname with it selected, so typing replaces it, + * Enter commits and Escape leaves the name alone. + */ +final class NicknameCellEditor extends DefaultTreeCellEditor { + + /** Long enough for the click's own release to have been handled first. */ + private static final int SELECT_DELAY_MS = 80; + + private final JTextField field; + /** Whether a client id is our own — the tree learns ours only once connected. */ + private final IntPredicate isSelf; + + private NicknameCellEditor(JTree tree, DefaultTreeCellRenderer renderer, JTextField field, + IntPredicate isSelf) { + super(tree, renderer, new DefaultCellEditor(field)); + this.field = field; + this.isSelf = isSelf; + } + + static NicknameCellEditor create(JTree tree, DefaultTreeCellRenderer renderer, IntPredicate isSelf) { + JTextField field = new JTextField(); + field.setFont(Theme.UI_FONT); + return new NicknameCellEditor(tree, renderer, field, isSelf); + } + + /** Whether a path is the row this editor works on. */ + boolean editsPath(TreePath path) { + ClientEntry client = clientOf(path); + return client != null && isSelf.test(client.id); + } + + /** + * Starts on a plain double click, rather than the click-pause-click (and its timer) + * that a file browser renames with — the tree's other double clicks act at once too. + */ + @Override + public boolean isCellEditable(EventObject event) { + if (!(event instanceof MouseEvent)) return false; + MouseEvent e = (MouseEvent) event; + if (e.getClickCount() != 2 || !SwingUtilities.isLeftMouseButton(e)) return false; + return editsPath(tree.getPathForRow(rowAt(e))); + } + + @Override + public Component getTreeCellEditorComponent(JTree tree, Object value, boolean isSelected, + boolean expanded, boolean leaf, int row) { + // The node itself has to reach super, whose renderer measures the row from it; + // the text it derives is the node's toString, so the nickname is filled in after. + Component c = super.getTreeCellEditorComponent(tree, value, isSelected, expanded, leaf, row); + ClientEntry client = clientOf(value); + if (client != null) { + field.setText(client.nickname); + field.selectAll(); + } + SwingUtilities.invokeLater(field::requestFocusInWindow); + // The double click that opened the field ends in a release, which a text + // component turns into a caret placement — undoing any selection made before + // it. Selecting after that has been delivered is what leaves the whole name + // selected, ready to be typed over. + Timer select = new Timer(SELECT_DELAY_MS, e -> field.selectAll()); + select.setRepeats(false); + select.start(); + return c; + } + + /** + * Keeps the row's own icon while it is being edited. The inherited version knows + * only the renderer's default leaf/branch icons, which would swap a client's state + * icon for a blank page for as long as the field is open. + */ + @Override + protected void determineOffset(JTree tree, Object value, boolean isSelected, boolean expanded, + boolean leaf, int row) { + super.determineOffset(tree, value, isSelected, expanded, leaf, row); + Component c = renderer.getTreeCellRendererComponent(tree, value, isSelected, expanded, leaf, + row, false); + if (!(c instanceof JLabel)) return; + editingIcon = ((JLabel) c).getIcon(); + offset = editingIcon == null ? 0 : editingIcon.getIconWidth() + renderer.getIconTextGap(); + } + + /** The edited nickname, trimmed; empty when nothing usable was typed. */ + String editedNickname() { + String text = field.getText(); + return text == null ? "" : text.trim(); + } + + private int rowAt(MouseEvent e) { + return tree instanceof DropIndicatorTree + ? ((DropIndicatorTree) tree).rowAt(e.getY()) + : tree.getRowForLocation(e.getX(), e.getY()); + } + + private static ClientEntry clientOf(TreePath path) { + return path == null ? null : clientOf(path.getLastPathComponent()); + } + + private static ClientEntry clientOf(Object node) { + if (!(node instanceof DefaultMutableTreeNode)) return null; + Object obj = ((DefaultMutableTreeNode) node).getUserObject(); + return obj instanceof ClientEntry ? (ClientEntry) obj : null; + } +} diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java index bb6464a..b0c9cbf 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTabTreeActions.java @@ -228,6 +228,11 @@ final class ServerTabTreeActions implements ServerTreePanel.Actions { })); } + @Override + public void renameSelf(String nickname) { + host.changeNickname(nickname); + } + @Override public void setChannelSubscribed(ChannelNode channel, boolean family, boolean subscribed) { if (conn.isConnected()) conn.setChannelsSubscribed(channel.familyIds(family), subscribed); diff --git a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java index c0d2fcc..469f7fa 100644 --- a/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java +++ b/ts3-client/swing/src/main/java/com/ts3client/ui/ServerTreePanel.java @@ -103,16 +103,31 @@ public final class ServerTreePanel extends JScrollPane { /** Edits a client's description — our own included. */ void changeClientDescription(ClientEntry client); + + /** A new nickname for ourselves, typed into the tree row. */ + void renameSelf(String nickname); } private final DropIndicatorTree tree; private final DefaultMutableTreeNode root = new DefaultMutableTreeNode(); - private final DefaultTreeModel treeModel = new DefaultTreeModel(root); + /** + * The nodes only mirror the connection's model, so an edited row is not written + * back into the tree: the new nickname goes to the server, and the row follows + * once it reports the rename. + */ + private final DefaultTreeModel treeModel = new DefaultTreeModel(root) { + @Override + public void valueForPathChanged(TreePath path, Object newValue) { + String nickname = String.valueOf(newValue).trim(); + if (!nickname.isEmpty()) actions.renameSelf(nickname); + } + }; /** True while {@link #rebuild()} clears and restores the selection, to swallow the transient null in between. */ private boolean rebuilding; private final ServerModel model; private final GroupIcons groupIcons; private final Actions actions; + private final NicknameCellEditor nicknameEditor; private int selfClientId = -1; public ServerTreePanel(ServerModel model, GroupIcons groupIcons, Actions actions) { @@ -134,7 +149,13 @@ public final class ServerTreePanel extends JScrollPane { tree.setRowHeight(20); tree.setBackground(Theme.TREE_BG); tree.setFont(Theme.UI_FONT); - tree.setCellRenderer(new ServerTreeCellRenderer()); + ServerTreeCellRenderer renderer = new ServerTreeCellRenderer(); + tree.setCellRenderer(renderer); + nicknameEditor = NicknameCellEditor.create(tree, renderer, id -> id == selfClientId); + tree.setCellEditor(nicknameEditor); + tree.setEditable(true); + tree.setPathEditable(nicknameEditor::editsPath); + tree.setInvokesStopCellEditing(true); setViewportView(tree); getViewport().setBackground(Theme.TREE_BG); // The icon strip is drawn against the viewport's right edge, so the blitted @@ -175,7 +196,7 @@ public final class ServerTreePanel extends JScrollPane { if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2) { if (obj instanceof ChannelNode && !Spacers.isSpacer(((ChannelNode) obj).name)) { actions.joinChannel(((ChannelNode) obj).id); - } else if (obj instanceof ClientEntry) { + } else if (obj instanceof ClientEntry && ((ClientEntry) obj).id != selfClientId) { actions.openPrivateChat((ClientEntry) obj); } } else if (SwingUtilities.isMiddleMouseButton(e) && obj instanceof ClientEntry) {