From 7b52b24215079b05ffc6affc91b91bb28f708a30 Mon Sep 17 00:00:00 2001 From: fourbites Date: Sat, 3 Jan 2026 19:45:40 +0100 Subject: [PATCH 1/4] Add tooltip support to config editor via _TIP convention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ObjectEditorPanel now looks up a static String constant named FIELD_NAME_TIP (e.g. myField → MY_FIELD_TIP) on the ConfigObject after calling getEditor(), and sets it as a tooltip on the editor panel. No changes to ConfigObject's API — existing getEditor() overrides continue to work and automatically receive tooltips. Co-Authored-By: Claude Opus 4.6 --- .../admin/client/ObjectEditorPanel.java | 28 +++++++- .../threerings/presents/client/Client.java | 66 +++++++++---------- .../presents/client/ClientDObjectMgr.java | 6 +- 3 files changed, 59 insertions(+), 41 deletions(-) diff --git a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java index c14c9e5c1..69c20717d 100644 --- a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java +++ b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java @@ -9,6 +9,7 @@ import java.lang.reflect.Field; import java.lang.reflect.Modifier; import javax.swing.BorderFactory; +import javax.swing.JPanel; import com.samskivert.swing.ScrollablePanel; import com.samskivert.swing.VGroupLayout; @@ -89,7 +90,7 @@ public class ObjectEditorPanel extends ScrollablePanel // if the field is anything but a plain old public field, // we don't want to edit it if (field.getModifiers() == Modifier.PUBLIC) { - add(_object.getEditor(_ctx, field)); + add(applyTip(object, field, _object.getEditor(_ctx, field))); } } @@ -106,6 +107,31 @@ public class ObjectEditorPanel extends ScrollablePanel log.warning("Unable to subscribe to config object: " + cause); } + /** + * Looks up a {@code FIELD_NAME_TIP} static String constant on the config object and applies + * it as a tooltip on the editor panel. + */ + protected static JPanel applyTip (ConfigObject object, Field field, JPanel editor) + { + String tipName = toUpperSnake(field.getName()) + "_TIP"; + try { + Field tipField = object.getClass().getField(tipName); + if (tipField.getType() == String.class && + Modifier.isStatic(tipField.getModifiers())) { + editor.setToolTipText((String) tipField.get(null)); + } + } catch (NoSuchFieldException | IllegalAccessException e) { + // no tip constant for this field + } + return editor; + } + + /** Converts a {@code lowerCamelCase} name to {@code UPPER_SNAKE_CASE}. */ + private static String toUpperSnake (String name) + { + return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(); + } + protected PresentsContext _ctx; protected String _key; protected SafeSubscriber _safesub; diff --git a/core/src/main/java/com/threerings/presents/client/Client.java b/core/src/main/java/com/threerings/presents/client/Client.java index a2cb216c4..52e99e3c5 100644 --- a/core/src/main/java/com/threerings/presents/client/Client.java +++ b/core/src/main/java/com/threerings/presents/client/Client.java @@ -870,11 +870,7 @@ public class Client } // otherwise queue this notification up to run on the run queue thread else { - _runQueue.postRunnable(new Runnable() { - public void run () { - _observers.apply(op); - } - }); + _runQueue.postRunnable(() -> _observers.apply(op)); } } @@ -894,38 +890,36 @@ public class Client // CLIENT_DID_LOGOFF; that may not have been invoked yet, so we don't want to clear out our // communicator reference immediately; instead we queue up a runnable unit to do so to // ensure that it won't happen until CLIENT_DID_LOGOFF was dispatched - _runQueue.postRunnable(new Runnable() { - public void run () { - // tell the object manager that we're no longer connected to the server - if (_omgr instanceof ClientDObjectMgr) { - ((ClientDObjectMgr)_omgr).cleanup(); - } - - // clear out our references - _comm = null; - _bstrap = null; - _omgr = null; - _clobj = null; - _connectionId = _cloid = -1; - _standalone = false; - - // and let our invocation director know we're logged off - _invdir.cleanup(); - - // if we were cleaned up due to a failure to logon, we can report the logon error - // now that the communicator is cleaned up; this allows a logon failure listener to - // immediately try another logon (hopefully with something changed like the server - // or port) - notifyObservers(new ObserverOps.Client(Client.this) { - @Override protected void notify (ClientObserver obs) { - if (logonError != null) { - obs.clientFailedToLogon(_client, logonError); - } else { - obs.clientDidClear(_client); - } - } - }); + _runQueue.postRunnable(() -> { + // tell the object manager that we're no longer connected to the server + if (_omgr instanceof ClientDObjectMgr) { + ((ClientDObjectMgr)_omgr).cleanup(); } + + // clear out our references + _comm = null; + _bstrap = null; + _omgr = null; + _clobj = null; + _connectionId = _cloid = -1; + _standalone = false; + + // and let our invocation director know we're logged off + _invdir.cleanup(); + + // if we were cleaned up due to a failure to logon, we can report the logon error + // now that the communicator is cleaned up; this allows a logon failure listener to + // immediately try another logon (hopefully with something changed like the server + // or port) + notifyObservers(new ObserverOps.Client(Client.this) { + @Override protected void notify (ClientObserver obs) { + if (logonError != null) { + obs.clientFailedToLogon(_client, logonError); + } else { + obs.clientDidClear(_client); + } + } + }); }); } diff --git a/core/src/main/java/com/threerings/presents/client/ClientDObjectMgr.java b/core/src/main/java/com/threerings/presents/client/ClientDObjectMgr.java index 49cfc089e..0a84a98d4 100644 --- a/core/src/main/java/com/threerings/presents/client/ClientDObjectMgr.java +++ b/core/src/main/java/com/threerings/presents/client/ClientDObjectMgr.java @@ -234,10 +234,8 @@ public class ClientDObjectMgr _flusher.cancel(); _flushes.clear(); _dead.clear(); - _client.getRunQueue().postRunnable(new Runnable() { - public void run () { - _ocache.clear(); - } + _client.getRunQueue().postRunnable(() -> { + _ocache.clear(); }); } From e949041aa04ebc23b21719e7ddf09f215f4726f7 Mon Sep 17 00:00:00 2001 From: fourbites Date: Tue, 17 Feb 2026 10:14:15 +0100 Subject: [PATCH 2/4] Add search to admin config --- .../admin/client/ConfigEditorPanel.java | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/core/src/main/java/com/threerings/admin/client/ConfigEditorPanel.java b/core/src/main/java/com/threerings/admin/client/ConfigEditorPanel.java index 8d7bc56bd..00c932ffb 100644 --- a/core/src/main/java/com/threerings/admin/client/ConfigEditorPanel.java +++ b/core/src/main/java/com/threerings/admin/client/ConfigEditorPanel.java @@ -5,15 +5,27 @@ package com.threerings.admin.client; +import java.awt.Color; import java.awt.Component; +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; +import java.util.ArrayList; import java.util.Comparator; +import java.util.List; + +import javax.swing.AbstractAction; +import javax.swing.KeyStroke; import javax.swing.JLabel; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JTabbedPane; +import javax.swing.JTextField; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; import com.samskivert.util.QuickSort; +import com.samskivert.swing.HGroupLayout; import com.samskivert.swing.VGroupLayout; import com.threerings.presents.util.PresentsContext; @@ -48,6 +60,55 @@ public class ConfigEditorPanel extends JPanel setLayout(new VGroupLayout(VGroupLayout.STRETCH, VGroupLayout.STRETCH, VGroupLayout.DEFAULT_GAP, VGroupLayout.CENTER)); + // add a search bar at the top + JPanel searchPanel = new JPanel(new HGroupLayout(HGroupLayout.STRETCH)); + searchPanel.add(new JLabel("Search: "), HGroupLayout.FIXED); + searchPanel.add(_searchField = new JTextField()); + searchPanel.add(_matchLabel = new JLabel(), HGroupLayout.FIXED); + add(searchPanel, VGroupLayout.FIXED); + + _searchField.getDocument().addDocumentListener(new DocumentListener() { + public void insertUpdate (DocumentEvent e) { filterTabs(); } + public void removeUpdate (DocumentEvent e) { filterTabs(); } + public void changedUpdate (DocumentEvent e) { filterTabs(); } + }); + + // bind Ctrl+F to focus the search field + getInputMap(WHEN_IN_FOCUSED_WINDOW).put( + KeyStroke.getKeyStroke(KeyEvent.VK_F, ActionEvent.CTRL_MASK), "focusSearch"); + getActionMap().put("focusSearch", new AbstractAction() { + public void actionPerformed (ActionEvent e) { + _searchField.requestFocusInWindow(); + _searchField.selectAll(); + } + }); + + // bind Escape to clear search and return focus + _searchField.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "clearSearch"); + _searchField.getActionMap().put("clearSearch", new AbstractAction() { + public void actionPerformed (ActionEvent e) { + _searchField.setText(""); + _oeditors.requestFocusInWindow(); + } + }); + + // bind Enter / Shift+Enter to cycle through matching tabs + _searchField.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, 0), "nextMatch"); + _searchField.getActionMap().put("nextMatch", new AbstractAction() { + public void actionPerformed (ActionEvent e) { + cycleMatch(1); + } + }); + _searchField.getInputMap().put( + KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, ActionEvent.SHIFT_MASK), "prevMatch"); + _searchField.getActionMap().put("prevMatch", new AbstractAction() { + public void actionPerformed (ActionEvent e) { + cycleMatch(-1); + } + }); + // create our objects tabbed pane add(_oeditors = new JTabbedPane(JTabbedPane.LEFT)); @@ -128,6 +189,129 @@ public class ConfigEditorPanel extends JPanel log.warning("Failed to get config info", "reason", reason); } + /** Filters tabs and highlights matching fields based on the current search text. */ + protected void filterTabs () + { + String query = _searchField.getText().trim().toLowerCase(); + boolean empty = query.isEmpty(); + + _matchingTabs.clear(); + int totalMatches = 0; + for (int ii = 0; ii < _oeditors.getTabCount(); ii++) { + boolean tabNameMatches = !empty && + _oeditors.getTitleAt(ii).toLowerCase().contains(query); + int fieldMatchCount = highlightFields(ii, empty ? null : query); + boolean matches = tabNameMatches || fieldMatchCount > 0; + _oeditors.setForegroundAt(ii, (empty || matches) ? null : MISMATCH_COLOR); + if (matches) { + _matchingTabs.add(ii); + } + totalMatches += fieldMatchCount; + } + + // update match count label + if (empty) { + _matchLabel.setText(""); + } else if (_matchingTabs.isEmpty()) { + _matchLabel.setText("No matches"); + } else { + _matchLabel.setText(totalMatches + " field" + (totalMatches != 1 ? "s" : "") + + " in " + _matchingTabs.size() + " tab" + (_matchingTabs.size() != 1 ? "s" : "")); + } + + _matchCursor = 0; + if (!_matchingTabs.isEmpty()) { + _oeditors.setSelectedIndex(_matchingTabs.get(0)); + scrollToFirstMatch(); + } + } + + /** + * Highlights matching field editors in the given tab. Returns the number of matching fields. + * Pass null query to clear all highlights. + */ + protected int highlightFields (int tabIndex, String query) + { + Component comp = _oeditors.getComponentAt(tabIndex); + if (!(comp instanceof JScrollPane)) { + return 0; + } + Component view = ((JScrollPane)comp).getViewport().getView(); + if (!(view instanceof ObjectEditorPanel)) { + return 0; + } + + int matchCount = 0; + for (Component child : ((ObjectEditorPanel)view).getComponents()) { + if (!(child instanceof FieldEditor)) { + continue; + } + FieldEditor editor = (FieldEditor)child; + boolean matches = query != null && fieldMatches(editor, query); + editor.setOpaque(matches); + editor.setBackground(matches ? HIGHLIGHT_COLOR : null); + // Also highlight the label so the color is visible through the child components + editor._label.setOpaque(matches); + editor._label.setBackground(matches ? HIGHLIGHT_COLOR : null); + editor.repaint(); + if (matches) { + matchCount++; + } + } + return matchCount; + } + + /** Checks whether a field editor matches the search query by name or tooltip. */ + protected boolean fieldMatches (JPanel editor, String query) + { + if (editor instanceof FieldEditor) { + if (((FieldEditor)editor)._field.getName().toLowerCase().contains(query)) { + return true; + } + } + String tip = editor.getToolTipText(); + return tip != null && tip.toLowerCase().contains(query); + } + + /** Cycles to the next or previous matching tab. */ + protected void cycleMatch (int direction) + { + if (_matchingTabs.isEmpty()) { + return; + } + _matchCursor = (_matchCursor + direction + _matchingTabs.size()) % _matchingTabs.size(); + _oeditors.setSelectedIndex(_matchingTabs.get(_matchCursor)); + scrollToFirstMatch(); + } + + /** Scrolls the currently selected tab's viewport to show the first matching field. */ + protected void scrollToFirstMatch () + { + int tabIndex = _oeditors.getSelectedIndex(); + if (tabIndex < 0) { + return; + } + Component comp = _oeditors.getComponentAt(tabIndex); + if (!(comp instanceof JScrollPane)) { + return; + } + Component view = ((JScrollPane)comp).getViewport().getView(); + if (!(view instanceof ObjectEditorPanel)) { + return; + } + String query = _searchField.getText().trim().toLowerCase(); + if (query.isEmpty()) { + return; + } + ObjectEditorPanel opanel = (ObjectEditorPanel)view; + for (Component child : opanel.getComponents()) { + if (child instanceof FieldEditor && fieldMatches((FieldEditor)child, query)) { + opanel.scrollRectToVisible(child.getBounds()); + break; + } + } + } + /** Our client context. */ protected PresentsContext _ctx; @@ -136,4 +320,22 @@ public class ConfigEditorPanel extends JPanel /** Our default tab pane. */ protected String _defaultPane; + + /** Search field for filtering config tabs. */ + protected JTextField _searchField; + + /** Label showing the number of search matches. */ + protected JLabel _matchLabel; + + /** Indices of tabs that match the current search query. */ + protected List _matchingTabs = new ArrayList<>(); + + /** Current position in the matching tabs list for Enter/Shift+Enter cycling. */ + protected int _matchCursor; + + /** Color used for non-matching tabs. */ + protected static final Color MISMATCH_COLOR = Color.LIGHT_GRAY; + + /** Background color used for matching field editors. */ + protected static final Color HIGHLIGHT_COLOR = new Color(255, 255, 150); } From 4abbdc4c0af2fffb95196320cd8d97ba85e4b122 Mon Sep 17 00:00:00 2001 From: "Ray J. Greenwell" Date: Fri, 20 Mar 2026 14:28:59 -0700 Subject: [PATCH 3/4] Use an annotation for tooltips rather than a separate static field. --- .../admin/client/ObjectEditorPanel.java | 19 +++---------------- .../threerings/admin/data/ToolTipText.java | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 16 deletions(-) create mode 100644 core/src/main/java/com/threerings/admin/data/ToolTipText.java diff --git a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java index 69c20717d..251cc3494 100644 --- a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java +++ b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java @@ -21,6 +21,7 @@ import com.threerings.presents.util.PresentsContext; import com.threerings.presents.util.SafeSubscriber; import com.threerings.admin.data.ConfigObject; +import com.threerings.admin.data.ToolTipText; import static com.threerings.admin.Log.log; @@ -113,25 +114,11 @@ public class ObjectEditorPanel extends ScrollablePanel */ protected static JPanel applyTip (ConfigObject object, Field field, JPanel editor) { - String tipName = toUpperSnake(field.getName()) + "_TIP"; - try { - Field tipField = object.getClass().getField(tipName); - if (tipField.getType() == String.class && - Modifier.isStatic(tipField.getModifiers())) { - editor.setToolTipText((String) tipField.get(null)); - } - } catch (NoSuchFieldException | IllegalAccessException e) { - // no tip constant for this field - } + ToolTipText tipAnno = field.getAnnotation(ToolTipText.class); + if (tipAnno != null) editor.setToolTipText(tipAnno.value()); return editor; } - /** Converts a {@code lowerCamelCase} name to {@code UPPER_SNAKE_CASE}. */ - private static String toUpperSnake (String name) - { - return name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toUpperCase(); - } - protected PresentsContext _ctx; protected String _key; protected SafeSubscriber _safesub; diff --git a/core/src/main/java/com/threerings/admin/data/ToolTipText.java b/core/src/main/java/com/threerings/admin/data/ToolTipText.java new file mode 100644 index 000000000..daed580d6 --- /dev/null +++ b/core/src/main/java/com/threerings/admin/data/ToolTipText.java @@ -0,0 +1,18 @@ +package com.threerings.admin.data; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.FIELD }) +public @interface ToolTipText +{ + /** + * The tool tip text to show an admin when they're editing a field. + */ + String value (); +} From 93b9cd72c1f9640e82fd0a0d504ae2994ad524ac Mon Sep 17 00:00:00 2001 From: "Ray J. Greenwell" Date: Fri, 20 Mar 2026 14:34:14 -0700 Subject: [PATCH 4/4] Inline the method so I don't have to correct the comment. It's so small now anyway. --- .../admin/client/ObjectEditorPanel.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java index 251cc3494..8cc692f63 100644 --- a/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java +++ b/core/src/main/java/com/threerings/admin/client/ObjectEditorPanel.java @@ -91,7 +91,10 @@ public class ObjectEditorPanel extends ScrollablePanel // if the field is anything but a plain old public field, // we don't want to edit it if (field.getModifiers() == Modifier.PUBLIC) { - add(applyTip(object, field, _object.getEditor(_ctx, field))); + JPanel editor = _object.getEditor(_ctx, field); + add(editor); + ToolTipText tipAnno = field.getAnnotation(ToolTipText.class); + if (tipAnno != null) editor.setToolTipText(tipAnno.value()); } } @@ -108,17 +111,6 @@ public class ObjectEditorPanel extends ScrollablePanel log.warning("Unable to subscribe to config object: " + cause); } - /** - * Looks up a {@code FIELD_NAME_TIP} static String constant on the config object and applies - * it as a tooltip on the editor panel. - */ - protected static JPanel applyTip (ConfigObject object, Field field, JPanel editor) - { - ToolTipText tipAnno = field.getAnnotation(ToolTipText.class); - if (tipAnno != null) editor.setToolTipText(tipAnno.value()); - return editor; - } - protected PresentsContext _ctx; protected String _key; protected SafeSubscriber _safesub;