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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user