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); } 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..8cc692f63 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; @@ -20,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; @@ -89,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(_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()); } } 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 (); +}