Rename yourself by double-clicking your own tree row

The row turns into a text field holding the current nickname, selected so
typing replaces it; Enter commits, Escape leaves the name alone. Only our
own row edits — every other double click keeps its meaning, and ours no
longer opens a private chat with ourselves.

The editor keeps the row's own icon (the inherited one knows only the
renderer's default leaf icon) and selects the text on a short delay,
after the release that opened it has been turned into a caret placement.
The tree nodes only mirror the connection's model, so the typed name is
sent to the server rather than written into the node; the row follows
when the rename is reported back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 19:38:49 +00:00
parent fa99cadc44
commit 6e65797f25
4 changed files with 168 additions and 3 deletions

View File

@@ -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<TreePath> 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<TreePath> 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) {

View File

@@ -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).
*
* <p>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;
}
}

View File

@@ -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);

View File

@@ -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) {