diff --git a/swing/src/main/java/info/openrocket/swing/gui/widgets/SearchableAndCategorizableComboBox.java b/swing/src/main/java/info/openrocket/swing/gui/widgets/SearchableAndCategorizableComboBox.java index 07984aec5..656e7f0a6 100644 --- a/swing/src/main/java/info/openrocket/swing/gui/widgets/SearchableAndCategorizableComboBox.java +++ b/swing/src/main/java/info/openrocket/swing/gui/widgets/SearchableAndCategorizableComboBox.java @@ -21,7 +21,6 @@ import javax.swing.SwingUtilities; import javax.swing.event.ListDataEvent; import javax.swing.event.ListDataListener; import javax.swing.event.ListSelectionEvent; -import javax.swing.event.ListSelectionListener; import javax.swing.plaf.basic.BasicArrowButton; import java.awt.BorderLayout; import java.awt.Color; @@ -60,6 +59,10 @@ import java.util.Vector; * @author Sibo Van Gool */ public class SearchableAndCategorizableComboBox> extends JComboBox { + private static final String CHECKMARK = "\u2713"; + private static final int CHECKMARK_X_OFFSET = 5; + private static final int CHECKMARK_Y_OFFSET = 5; + private final String placeHolderText; private JPopupMenu categoryPopup; private JPopupMenu searchPopup; @@ -75,8 +78,6 @@ public class SearchableAndCategorizableComboBox { - String text = searchFieldCategory.getText(); - highlightedListIdx = 0; // Start with the first item selected - searchFieldSearch.setText(text); - if (!text.isEmpty() && !searchPopup.isVisible()) { - hideCategoryPopup(); - showSearchPopup(); - filter(text); - } - }); - } - }); - searchFieldSearch.addKeyListener(new KeyAdapter() { - @Override - public void keyPressed(KeyEvent e) { - overrideActionKeys(e); - } - - @Override - public void keyTyped(KeyEvent e) { - EventQueue.invokeLater(() -> { - String text = searchFieldSearch.getText(); - highlightedListIdx = 0; // Start with the first item selected - searchFieldCategory.setText(text); - if (text.isEmpty() && !categoryPopup.isVisible()) { - hideSearchPopup(); - showCategoryPopup(); - } - filter(text); - }); - } - }); - // Fix a bug where the first character would get selected when the search field gets focus (thus deleting it on - // the next key press) - searchFieldSearch.addFocusListener(new FocusAdapter() { - @Override - public void focusGained(FocusEvent e) { - SwingUtilities.invokeLater(() -> { - searchFieldSearch.setCaretPosition(searchFieldSearch.getText().length()); - }); - } - }); - - // Override the mouse listeners to use our custom popup - for (MouseListener mouseListener : getMouseListeners()) { - removeMouseListener(mouseListener); - } - + setupModelListener(model); + setupSearchFieldListeners(); addMouseListeners(); } @@ -283,7 +210,7 @@ public class SearchableAndCategorizableComboBox createFilteredList() { - JList list = new JList<>(); // Don't fill the list with the items yet, this will be done during filtering + JList list = new JList<>(); - list.setCellRenderer(new DefaultListCellRenderer() { - @Override - public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { - JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); - T item = (T) value; - String itemName = getDisplayString(item); - - // If the item is currently selected, draw a checkmark before it - if (item == getSelectedItem()) { - itemName = CHECKMARK + " " + itemName; - } - - if (itemName.toLowerCase().contains(searchFieldSearch.getText().toLowerCase())) { - // Use HTML to underline matching text - itemName = itemName.replaceAll("(?i)(" + searchFieldSearch.getText() + ")", "$1"); - label.setText("" + itemName + ""); - } - - // Set the hover color - if (highlightedListIdx == index || isSelected) { - label.setBackground(textSelectionBackground); - label.setOpaque(true); - } else { - label.setOpaque(false); - } - - return label; - } - }); - - list.addMouseMotionListener(new MouseAdapter() { - @Override - public void mouseMoved(MouseEvent e) { - Point p = new Point(e.getX(),e.getY()); - int index = list.locationToIndex(p); - if (index != highlightedListIdx) { - highlightedListIdx = index; - list.repaint(); - } - } - }); - - list.addListSelectionListener(new ListSelectionListener() { - @Override - public void valueChanged(ListSelectionEvent e) { - // Check if the event is in the final phase of change - if (!e.getValueIsAdjusting()) { - selectComboBoxItemFromFilteredList(); - } - } - }); + list.setCellRenderer(new FilteredListCellRenderer()); + list.addMouseMotionListener(new FilteredListMouseMotionAdapter()); + list.addListSelectionListener(this::onFilteredListSelectionChanged); return list; } + private void onFilteredListSelectionChanged(ListSelectionEvent e) { + if (!e.getValueIsAdjusting()) { + selectComboBoxItemFromFilteredList(); + } + } + private void selectComboBoxItemFromFilteredList() { SwingUtilities.invokeLater(new Runnable() { @Override @@ -404,13 +289,17 @@ public class SearchableAndCategorizableComboBox hidePopups(); + case KeyEvent.VK_ENTER -> selectHighlightedItemInFilteredList(); + case KeyEvent.VK_DOWN, KeyEvent.VK_RIGHT -> highlightNextItemInFilteredList(); + case KeyEvent.VK_UP, KeyEvent.VK_LEFT -> highlightPreviousItemInFilteredList(); } } @@ -490,11 +376,10 @@ public class SearchableAndCategorizableComboBox= filteredList.getModel().getSize() || highlightedListIdx < 0 || !searchPopup.isVisible()) { - return; + if (highlightedListIdx >= 0 && highlightedListIdx < filteredList.getModel().getSize() && searchPopup.isVisible()) { + filteredList.setSelectedIndex(highlightedListIdx); + selectComboBoxItemFromFilteredList(); } - filteredList.setSelectedIndex(highlightedListIdx); - selectComboBoxItemFromFilteredList(); } /** @@ -502,11 +387,10 @@ public class SearchableAndCategorizableComboBox= filteredList.getModel().getSize() || !searchPopup.isVisible()) { - return; + highlightedListIdx++; + filteredList.ensureIndexIsVisible(highlightedListIdx); + filteredList.repaint(); } - highlightedListIdx++; - filteredList.ensureIndexIsVisible(highlightedListIdx); - filteredList.repaint(); } /** @@ -521,12 +405,53 @@ public class SearchableAndCategorizableComboBox model) { + if (model == null) { + return; + } + model.addListDataListener(new ListDataListener() { + @Override + public void intervalAdded(ListDataEvent e) { + updateItemsFromModel(); + } + @Override + public void intervalRemoved(ListDataEvent e) { + updateItemsFromModel(); + } + + @Override + public void contentsChanged(ListDataEvent e) { + updateItemsFromModel(); + } + }); + } + + private void setupSearchFieldListeners() { + searchFieldCategory.addKeyListener(new SearchFieldKeyAdapter(searchFieldCategory, searchFieldSearch, true)); + searchFieldSearch.addKeyListener(new SearchFieldKeyAdapter(searchFieldSearch, searchFieldCategory, false)); + + // Fix a bug where the first character would get selected when the search field gets focus (thus deleting it on + // the next key press) + searchFieldSearch.addFocusListener(new FocusAdapter() { + @Override + public void focusGained(FocusEvent e) { + SwingUtilities.invokeLater(() -> { + searchFieldSearch.setCaretPosition(searchFieldSearch.getText().length()); + }); + } + }); + } /** * Add mouse listener to widgets of the combobox to open our custom popup menu. */ private void addMouseListeners() { + // Override the mouse listeners to use our custom popup + for (MouseListener mouseListener : getMouseListeners()) { + removeMouseListener(mouseListener); + } + addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { @@ -562,6 +487,91 @@ public class SearchableAndCategorizableComboBox { + String text = primaryField.getText(); + highlightedListIdx = 0; + secondaryField.setText(text); + if (isCategory) { + handleCategorySearch(text); + } else { + handleGeneralSearch(text); + } + filter(text); + }); + } + + private void handleCategorySearch(String text) { + if (!text.isEmpty() && !searchPopup.isVisible()) { + hideCategoryPopup(); + showSearchPopup(); + } + } + + private void handleGeneralSearch(String text) { + if (text.isEmpty() && !categoryPopup.isVisible()) { + hideSearchPopup(); + showCategoryPopup(); + } + } + } + + private class FilteredListCellRenderer extends DefaultListCellRenderer { + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + JLabel label = (JLabel) super.getListCellRendererComponent(list, value, index, isSelected, cellHasFocus); + T item = (T) value; + String itemName = getDisplayString(item); + + if (item == getSelectedItem()) { + itemName = CHECKMARK + " " + itemName; + } + + if (itemName.toLowerCase().contains(searchFieldSearch.getText().toLowerCase())) { + itemName = itemName.replaceAll("(?i)(" + searchFieldSearch.getText() + ")", "$1"); + label.setText("" + itemName + ""); + } + + if (highlightedListIdx == index || isSelected) { + label.setBackground(textSelectionBackground); + label.setOpaque(true); + } else { + label.setOpaque(false); + } + + return label; + } + } + + private class FilteredListMouseMotionAdapter extends MouseAdapter { + @Override + public void mouseMoved(MouseEvent e) { + Point p = new Point(e.getX(), e.getY()); + int index = filteredList.locationToIndex(p); + if (index != highlightedListIdx) { + highlightedListIdx = index; + filteredList.repaint(); + } + } + } + private static class SortedListModel extends AbstractListModel { private final SortedSet model;