Close

Java Swing - JMenu Search Highlighting

[Updated: Nov 25, 2018, Created: Nov 24, 2018]

This example shows how to add search and highlighting functionality in JMenu items. This can be useful for very large menus or dynamic menu where shortcut keys are not possible or not user friendly.

We are going to use custom search popup component approach. Also we are going to reuse code from LabelHighlighted (a custom JLabel) to show highlighting for search results.

This example does not filter the menu items by removing unmatched items but just highlights the matched items. Once there're matches, user can jump to the next match by up/down arrow keys. To cancel a match, backspace or escape keys can be used.

Main class

public class MenuBuilderExampleMain {
  public static void main(String[] args) {
      setFonts();
      //to display menu selection
      JLabel selectionLabel = new JLabel();
      //creating menu bar
      JMenuBar jMenuBar = new JMenuBar();
      JMenu menu = new JMenu("File");//just an empty menu
      jMenuBar.add(menu);
      menu = buildExampleMenu(selectionLabel);
      jMenuBar.add(menu);

      JFrame frame = createFrame();
      frame.setLayout(new GridBagLayout());
      frame.add(selectionLabel);
      frame.setJMenuBar(jMenuBar);
      frame.setLocationRelativeTo(null);
      frame.setVisible(true);
  }

  private static JMenu buildExampleMenu(JLabel selectionLabel) {
      //just add some transparency
      Color color = UIManager.getColor("MenuItem.selectionBackground");
      UIManager.put("MenuItem.selectionBackground",
              new Color(color.getRed(), color.getGreen(), color.getBlue(), 135));

      //selecting a menu will update the JLabel
      ActionListener al = (ae) -> selectionLabel.setText(ae.getActionCommand());

      //building menu capable for searching
      JMenu exampleMenu = new JMenu("Example Menu");
      exampleMenu.setMnemonic(KeyEvent.VK_E);
      for (int i = 0; i < 30; i++) {
          //menu item must be HighlightedMenuItem subclass of JMenuItem
          HighlightedMenuItem menuItem = new HighlightedMenuItem();
          menuItem.setText(RandomUtil.getFullName());
          menuItem.addActionListener(al);
          exampleMenu.add(menuItem);
      }
      MenuSearchDecorator.decorate(exampleMenu);
      return exampleMenu;
  }

  private static JFrame createFrame() {
      JFrame frame = new JFrame("Menu Search Example");
      frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
      frame.setSize(new Dimension(600, 400));
      return frame;
  }

  private static void setFonts() {
      System.setProperty("swing.aatext", "true");
      System.setProperty("swing.plaf.metal.controlFont", "Tahoma-14");
      System.setProperty("swing.plaf.metal.userFont", "Tahoma-14");
  }
}

The decorator

public class MenuSearchDecorator {
  private JMenu[] menus;

  public MenuSearchDecorator(JMenu[] menus) {
      this.menus = menus;
  }

  //the provided menus must add HighlightedMenuItem
  public static void decorate(JMenu... menus) {
      if (menus == null) {
          throw new IllegalArgumentException("menus cannot be null");
      }
      new MenuSearchDecorator(menus).init();
  }

  private void init() {
      for (JMenu menu : menus) {
          SearchPopupHandler searchPopupHandler =
                  new SearchPopupHandler(menu.getPopupMenu(), text -> performSearch(text, menu));
          searchPopupHandler.init();
          menu.addMenuKeyListener(createKeyListener(menu, searchPopupHandler));
          menu.getPopupMenu().addPopupMenuListener(createPopupMenuListener(menu, searchPopupHandler));
      }
  }

  private PopupMenuListener createPopupMenuListener(JMenu menu, SearchPopupHandler searchPopupHandler) {
      return new PopupMenuListener() {
          @Override
          public void popupMenuWillBecomeVisible(PopupMenuEvent e) {

          }

          @Override
          public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
              searchPopupHandler.resetSearchPopup();

          }

          @Override
          public void popupMenuCanceled(PopupMenuEvent e) {

          }
      };
  }

  private MenuKeyListener createKeyListener(JMenu menu, SearchPopupHandler searchPopupHandler) {

      return new MenuKeyListener() {

          @Override
          public void menuKeyTyped(MenuKeyEvent e) {
          }

          @Override
          public void menuKeyPressed(MenuKeyEvent e) {
              KeyEvent ke = new KeyEvent(menu, e.getID(),
                      e.getWhen(),
                      e.getModifiersEx(),
                      e.getKeyCode(),
                      e.getKeyChar(),
                      e.getKeyLocation());
              searchPopupHandler.handleKeyPressedEvent(ke);
              if (ke.isConsumed()) {
                  e.consume();
                  return;
              }

              int keyCode = e.getKeyCode();
              if (keyCode == KeyEvent.VK_DOWN || keyCode == KeyEvent.VK_UP) {
                  if (searchPopupHandler.hasMatches()) {
                      //if there're matches then up/down keys will jump to previous/next match
                      // this feature is similar to intellij project-explorer/file-dialog search
                      if (jumpToOtherMatch(menu, searchPopupHandler.getSearchText(), keyCode == KeyEvent.VK_DOWN)) {
                          e.consume();
                      }
                  }
              }
          }

          @Override
          public void menuKeyReleased(MenuKeyEvent e) {
          }
      };
  }

  private boolean jumpToOtherMatch(JMenu menu, String searchText, boolean forward) {
      MenuElement last = JMenuUtil.getCurrentSelection();

      if (last instanceof HighlightedMenuItem) {
          List<Component> menuComponents = new ArrayList<>(List.of(menu.getMenuComponents()));
          if (!forward) {
              //just reverse it, instead of doing reversed loop
              Collections.reverse(menuComponents);
          }
          int currentIndex = menuComponents.indexOf(last);

          int size = menuComponents.size();
          //find next match
          for (int i = currentIndex + 1; i < size; i++) {
              Component component = menuComponents.get(i);
              if (component instanceof HighlightedMenuItem) {
                  if (((HighlightedMenuItem) component).highlightText(searchText)) {
                      JMenuUtil.setCurrentSiblingSelection((MenuElement) component);
                      return true;
                  }
              }
          }
      }
      return false;
  }

  private static boolean performSearch(String text, JMenu menu) {
      boolean match = false;
      for (Component menuComponent : menu.getMenuComponents()) {
          if (menuComponent instanceof HighlightedMenuItem) {
              match |= ((HighlightedMenuItem) menuComponent).highlightText(text);
          }
      }
      return match;
  }
}

The popup component

This class can be reused for other components as well:

//A reusable search handler which shows a search popup and
// uses in memory key inputs
public class SearchPopupHandler {
  private Popup searchPopup;
  private JLabel searchLabel;
  private JComponent userSearchComponent;
  private Predicate<String> userSearchHandler;
  private TextHandler textHandler = new TextHandler();
  private boolean hasMatches;

  public SearchPopupHandler(JComponent userSearchComponent, Predicate<String> userSearchHandler) {
      this.userSearchComponent = userSearchComponent;
      this.userSearchHandler = userSearchHandler;
  }

  public void init() {
      initSearchLabel();
  }

  //handles stuff related to popup text component
  public void handleKeyPressedEvent(KeyEvent e) {
      char keyChar = e.getKeyChar();
      if (!Character.isDefined(keyChar)) {
          return;
      }
      int keyCode = e.getKeyCode();
      switch (keyCode) {
          case KeyEvent.VK_DELETE:
              return;
          case KeyEvent.VK_ENTER:
              resetSearchPopup();
              return;
          case KeyEvent.VK_ESCAPE:
              if (resetSearchPopup()) {
                  e.consume();
              }
              return;
          case KeyEvent.VK_BACK_SPACE:
              textHandler.removeCharAtEnd();
              break;
          default:
              textHandler.add(keyChar);
      }

      if (!textHandler.text.isEmpty()) {
          showSearchPopup();
          performSearch();
      } else {
          resetSearchPopup();
      }
      e.consume();
  }

  public String getSearchText() {
      return searchLabel.getText();
  }

  private void initSearchLabel() {
      searchLabel = new JLabel();
      searchLabel.setOpaque(true);
      searchLabel.setFont(searchLabel.getFont().deriveFont(Font.PLAIN)
                                     .deriveFont(userSearchComponent.getFont().getSize()));
      searchLabel.setBorder(new CompoundBorder(BorderFactory.createLineBorder(Color.gray),
              BorderFactory.createEmptyBorder(4, 4, 4, 4)));
  }

  private void showSearchPopup() {
      if (textHandler.getText().isEmpty()) {
          return;
      }
      if (searchPopup == null) {
          Point p = new Point(0, 0);
          SwingUtilities.convertPointToScreen(p, userSearchComponent);
          Dimension comboSize = userSearchComponent.getPreferredSize();
          int height = searchLabel.getFontMetrics(searchLabel.getFont()).getHeight();
          Insets borderInsets = searchLabel.getBorder().getBorderInsets(searchLabel);
          height += borderInsets.top + borderInsets.bottom;
          searchLabel.setPreferredSize(new Dimension(comboSize.width, height));
          searchPopup = PopupFactory.getSharedInstance().getPopup(userSearchComponent, searchLabel, p.x,
                  p.y - height);
      }
      searchPopup.show();
  }

  public boolean resetSearchPopup() {
      if (!textHandler.isEditing()) {
          return false;
      }
      if (searchPopup != null) {
          searchPopup.hide();
          searchPopup = null;
          searchLabel.setText("");
          textHandler.reset();
          userSearchHandler.test("");
          return true;
      }
      return false;
  }

  private void performSearch() {
      searchLabel.setText(textHandler.getText());
      if (userSearchHandler.test(textHandler.getText())) {
          searchLabel.setForeground(Color.blue);
          hasMatches = true;
      } else {
          //if no match then red font
          searchLabel.setForeground(Color.red);
          hasMatches = false;
      }
  }

  public boolean hasMatches() {
      return hasMatches;
  }
}

The custom JMenuItem for highlighting

In this example we have separated highlighting logic from the actual component by using Java 8 default methods:

//very less code after moving logic up the hierarchy.
//also this design demonstrates how to do multiple
//inheritance by using Java 8 default methods
public class HighlightedMenuItem extends JMenuItem implements HighlightedComponent {
  private List<Rectangle2D> rectangles = new ArrayList<>();

  public HighlightedMenuItem() {
      setOpaque(false);
      //try with icon:
      //setIcon(UIManager.getIcon("OptionPane.questionIcon"));
  }

  @Override
  public Collection<Rectangle2D> getHighlightedRectangles() {
      return rectangles;
  }

  @Override
  protected void paintComponent(Graphics g) {
      doHighlightPainting(g);
      super.paintComponent(g);

  }
}
//using Java 8 default methods to reuse the same logic for different components
public interface HighlightedComponent extends IText {

  public static Color colorHighlight = new Color(220, 220, 50);

  default void reset() {
      getHighlightedRectangles().clear();
      repaint();
  }

  default boolean highlightText(String textToHighlight) {
      if (textToHighlight == null) {
          return false;
      }
      reset();

      final String textToMatch = textToHighlight.toLowerCase().trim();
      if (textToMatch.length() == 0) {
          return false;
      }
      textToHighlight = textToHighlight.trim();

      final String labelText = getText().toLowerCase();
      if (labelText.contains(textToMatch)) {
          FontMetrics fm = getFontMetrics(getFont());
          float w = -1;
          final float h = fm.getHeight() - 1;
          int i = 0;
          while (true) {
              i = labelText.indexOf(textToMatch, i);
              if (i == -1) {
                  break;
              }
              if (w == -1) {
                  String matchingText = getText().substring(i,
                          i + textToHighlight.length());
                  w = fm.stringWidth(matchingText);
              }
              String preText = getText().substring(0, i);
              float x = fm.stringWidth(preText);
              int y = 0;

              //taking care of margins if there's border
              if (getBorder() != null) {
                  Insets borderInsets = getBorder().getBorderInsets((Component) this);
                  if (borderInsets != null) {
                      x += borderInsets.left;
                      y += borderInsets.top;
                  }
              }
              //taking care of margin if there's icon
              if (getIcon() != null) {//assuming LEFT_TO_RIGHT orientation
                  x += getIcon().getIconWidth();
                  if (getIcon().getIconHeight() > fm.getHeight()) {
                      y += 1 + (getIcon().getIconHeight() - fm.getHeight()) / 2;//vertical middle
                  }
              }

              //taking care of left margin for icon-text-gap
              int gap = getIconTextGap();
              //gap is on both sides of the icon
              x += getIcon() != null ? gap * 2 : gap;


              getHighlightedRectangles().add(new Rectangle2D.Float(x, y, w, h));
              i = i + textToMatch.length();
          }
          repaint();
          return true;
      }
      return false;
  }

  //to be called from subclass's paintComponent(g)
  default void doHighlightPainting(Graphics g) {
      if (isOpaque()) {
          g.setColor(getBackground());
          g.fillRect(0, 0, getWidth(), getHeight());
      }

      if (getHighlightedRectangles().size() > 0) {

          Graphics2D g2d = (Graphics2D) g;
          Color c = g2d.getColor();
          for (Rectangle2D rectangle : getHighlightedRectangles()) {
              g2d.setColor(colorHighlight);
              g2d.fill(rectangle);
              g2d.setColor(Color.LIGHT_GRAY);
              g2d.draw(rectangle);
          }
          g2d.setColor(c);
      }
  }

  //since interfaces cannot have instance variables ask subclass to provide one
  Collection<Rectangle2D> getHighlightedRectangles();
}
//an interface representing component which
// has text, e.g. JTextComponent, JLabel, JButton, JMenuItem etc
public interface IText extends IComponent {

  int getIconTextGap();

  String getText();
}
//interface representing a JComponent
public interface IComponent {

  FontMetrics getFontMetrics(Font f);

  Font getFont();

  int getHeight();

  int getWidth();

  Color getBackground();

  boolean isOpaque();

  Icon getIcon();

  Border getBorder();

  void repaint();
}

Instead of adding all abstract methods in HighlightedComponent interface, we created IText and IComponent interfaces, this is to demonstrate how to create interface based JComponent hierarchy so that it can be reused for different purposes, where we want to add common logic in default methods. Generally, this design shows how to do multiple inheritance via Java 8 default methods.

Output

Example Project

Dependencies and Technologies Used:

  • JDK 11
  • Maven 3.5.4

Java Swing - JMenu Search Highlighting Select All Download
  • swing-menu-quick-search
    • src
      • main
        • java
          • com
            • logicbig
              • example
              • menu
                • MenuSearchDecorator.java
                • uicommon

    See Also