From 0eff06096bc4852f2580f20a0c5bf970ecf66987 Mon Sep 17 00:00:00 2001 From: 2xsaiko Date: Wed, 29 Apr 2020 18:24:29 +0200 Subject: Rewrite search dialog (#233) * Fix searching * Make buttons use localization * Fix rename field opening when pressing shift+space * Tweak search algorithm * Add a bit of documentation * Remove duplicate example line * Use max() when building the inner map instead of overwriting the old value * Keep search dialog state * Formatting * Fix cursor key selection not scrolling to selected item * Don't set font size * Rename close0 to exit * Fix wrong scrolling when selecting search dialog entry--- README.md | 1 - build.gradle | 1 - src/main/java/cuchaz/enigma/gui/ClassSelector.java | 24 +- src/main/java/cuchaz/enigma/gui/Gui.java | 29 +- .../cuchaz/enigma/gui/dialog/SearchDialog.java | 310 ++++++++++++++------- .../java/cuchaz/enigma/gui/elements/MenuBar.java | 12 +- .../java/cuchaz/enigma/gui/panels/PanelEditor.java | 2 +- .../enigma/gui/util/AbstractListCellRenderer.java | 75 +++++ .../java/cuchaz/enigma/gui/util/ScaleUtil.java | 6 + .../cuchaz/enigma/utils/search/SearchEntry.java | 17 ++ .../cuchaz/enigma/utils/search/SearchUtil.java | 195 +++++++++++++ src/main/resources/lang/en_us.json | 2 + 12 files changed, 550 insertions(+), 124 deletions(-) create mode 100644 src/main/java/cuchaz/enigma/gui/util/AbstractListCellRenderer.java create mode 100644 src/main/java/cuchaz/enigma/utils/search/SearchEntry.java create mode 100644 src/main/java/cuchaz/enigma/utils/search/SearchUtil.java diff --git a/README.md b/README.md index 24fc9c5..7d01dac 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Enigma includes the following open-source libraries: - [Guava](https://github.com/google/guava) (Apache-2.0) - [SyntaxPane](https://github.com/Sciss/SyntaxPane) (Apache-2.0) - [Darcula](https://github.com/bulenkov/Darcula) (Apache-2.0) - - [fuzzywuzzy](https://github.com/xdrop/fuzzywuzzy/) (GPL-3.0) - [jopt-simple](https://github.com/jopt-simple/jopt-simple) (MIT) - [ASM](https://asm.ow2.io/) (BSD-3-Clause) diff --git a/build.gradle b/build.gradle index f771ec7..a42b225 100644 --- a/build.gradle +++ b/build.gradle @@ -53,7 +53,6 @@ dependencies { implementation 'net.fabricmc:cfr:0.0.1' implementation 'com.bulenkov:darcula:1.0.0-bobbylight' implementation 'de.sciss:syntaxpane:1.2.0' - implementation 'me.xdrop:fuzzywuzzy:1.2.0' implementation 'com.github.lukeu:swing-dpi:0.6' testImplementation 'junit:junit:4.+' diff --git a/src/main/java/cuchaz/enigma/gui/ClassSelector.java b/src/main/java/cuchaz/enigma/gui/ClassSelector.java index 5051032..a23e24c 100644 --- a/src/main/java/cuchaz/enigma/gui/ClassSelector.java +++ b/src/main/java/cuchaz/enigma/gui/ClassSelector.java @@ -11,6 +11,17 @@ package cuchaz.enigma.gui; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.*; + +import javax.annotation.Nullable; +import javax.swing.JOptionPane; +import javax.swing.JTree; +import javax.swing.event.CellEditorListener; +import javax.swing.event.ChangeEvent; +import javax.swing.tree.*; + import com.google.common.collect.ArrayListMultimap; import com.google.common.collect.Lists; import com.google.common.collect.Maps; @@ -21,15 +32,6 @@ import cuchaz.enigma.throwables.IllegalNameException; import cuchaz.enigma.translation.Translator; import cuchaz.enigma.translation.representation.entry.ClassEntry; -import javax.annotation.Nullable; -import javax.swing.*; -import javax.swing.event.CellEditorListener; -import javax.swing.event.ChangeEvent; -import javax.swing.tree.*; -import java.awt.event.MouseAdapter; -import java.awt.event.MouseEvent; -import java.util.*; - public class ClassSelector extends JTree { public static final Comparator DEOBF_CLASS_COMPARATOR = Comparator.comparing(ClassEntry::getFullName); @@ -420,7 +422,9 @@ public class ClassSelector extends JTree { for (ClassSelectorPackageNode packageNode : packageNodes()) { for (ClassSelectorClassNode classNode : classNodes(packageNode)) { if (classNode.getClassEntry().equals(classEntry)) { - setSelectionPath(new TreePath(new Object[]{getModel().getRoot(), packageNode, classNode})); + TreePath path = new TreePath(new Object[]{getModel().getRoot(), packageNode, classNode}); + setSelectionPath(path); + scrollPathToVisible(path); } } } diff --git a/src/main/java/cuchaz/enigma/gui/Gui.java b/src/main/java/cuchaz/enigma/gui/Gui.java index 8f0d6fa..3412cd5 100644 --- a/src/main/java/cuchaz/enigma/gui/Gui.java +++ b/src/main/java/cuchaz/enigma/gui/Gui.java @@ -33,6 +33,7 @@ import cuchaz.enigma.config.Config; import cuchaz.enigma.config.Themes; import cuchaz.enigma.gui.dialog.CrashDialog; import cuchaz.enigma.gui.dialog.JavadocDialog; +import cuchaz.enigma.gui.dialog.SearchDialog; import cuchaz.enigma.gui.elements.MenuBar; import cuchaz.enigma.gui.elements.PopupMenuBar; import cuchaz.enigma.gui.filechooser.FileChooserAny; @@ -67,6 +68,7 @@ public class Gui { public FileDialog jarFileChooser; public FileDialog tinyMappingsFileChooser; + public SearchDialog searchDialog; public JFileChooser enigmaMappingsFileChooser; public JFileChooser exportSourceFileChooser; public FileDialog exportJarFileChooser; @@ -811,16 +813,15 @@ public class Gui { public void close() { if (!this.controller.isDirty()) { // everything is saved, we can exit safely - this.frame.dispose(); - System.exit(0); + exit(); } else { // ask to save before closing showDiscardDiag((response) -> { if (response == JOptionPane.YES_OPTION) { this.saveMapping(); - this.frame.dispose(); + exit(); } else if (response == JOptionPane.NO_OPTION) { - this.frame.dispose(); + exit(); } return null; @@ -828,6 +829,14 @@ public class Gui { } } + private void exit() { + if (searchDialog != null) { + searchDialog.dispose(); + } + this.frame.dispose(); + System.exit(0); + } + public void redraw() { this.frame.validate(); this.frame.repaint(); @@ -892,6 +901,10 @@ public class Gui { this.obfPanel.obfClasses.restoreExpansionState(this.obfPanel.obfClasses, stateObf); } + public PanelObf getObfPanel() { + return obfPanel; + } + public PanelDeobf getDeobfPanel() { return deobfPanel; } @@ -899,4 +912,12 @@ public class Gui { public void setShouldNavigateOnClick(boolean shouldNavigateOnClick) { this.shouldNavigateOnClick = shouldNavigateOnClick; } + + public SearchDialog getSearchDialog() { + if (searchDialog == null) { + searchDialog = new SearchDialog(this); + } + return searchDialog; + } + } diff --git a/src/main/java/cuchaz/enigma/gui/dialog/SearchDialog.java b/src/main/java/cuchaz/enigma/gui/dialog/SearchDialog.java index 56ce751..b36ebfb 100644 --- a/src/main/java/cuchaz/enigma/gui/dialog/SearchDialog.java +++ b/src/main/java/cuchaz/enigma/gui/dialog/SearchDialog.java @@ -11,150 +11,248 @@ package cuchaz.enigma.gui.dialog; -import com.google.common.collect.Lists; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.event.*; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import javax.swing.*; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; + import cuchaz.enigma.gui.Gui; +import cuchaz.enigma.gui.GuiController; +import cuchaz.enigma.gui.util.AbstractListCellRenderer; +import cuchaz.enigma.gui.util.ScaleUtil; import cuchaz.enigma.translation.representation.entry.ClassEntry; import cuchaz.enigma.utils.I18n; -import cuchaz.enigma.gui.util.ScaleUtil; -import me.xdrop.fuzzywuzzy.FuzzySearch; -import me.xdrop.fuzzywuzzy.model.ExtractedResult; - -import javax.swing.*; -import javax.swing.border.EmptyBorder; -import java.awt.*; -import java.awt.event.*; -import java.util.List; -import java.util.function.Consumer; -import java.util.stream.Collectors; +import cuchaz.enigma.utils.search.SearchEntry; +import cuchaz.enigma.utils.search.SearchUtil; public class SearchDialog { - private JTextField searchField; - private JList classList; - private JFrame frame; - - private Gui parent; - private List deobfClasses; + private final JTextField searchField; + private final DefaultListModel classListModel; + private final JList classList; + private final JDialog dialog; - private KeyEventDispatcher keyEventDispatcher; + private final Gui parent; + private final SearchUtil su; public SearchDialog(Gui parent) { this.parent = parent; - deobfClasses = Lists.newArrayList(); - this.parent.getController().addSeparatedClasses(Lists.newArrayList(), deobfClasses); - deobfClasses.removeIf(ClassEntry::isInnerClass); - } + su = new SearchUtil<>(); - public void show() { - frame = new JFrame(I18n.translate("menu.view.search")); - frame.setVisible(false); - JPanel pane = new JPanel(); - pane.setBorder(new EmptyBorder(5, 10, 5, 10)); - - addRow(pane, jPanel -> { - searchField = new JTextField("", 20); - - searchField.addKeyListener(new KeyAdapter() { - @Override - public void keyTyped(KeyEvent keyEvent) { - updateList(); - } - }); + dialog = new JDialog(parent.getFrame(), I18n.translate("menu.view.search"), true); + JPanel contentPane = new JPanel(); + contentPane.setBorder(ScaleUtil.createEmptyBorder(4, 4, 4, 4)); + contentPane.setLayout(new BorderLayout(ScaleUtil.scale(4), ScaleUtil.scale(4))); - jPanel.add(searchField); - }); - - addRow(pane, jPanel -> { - classList = new JList<>(); - classList.setLayoutOrientation(JList.VERTICAL); - classList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); - - classList.addMouseListener(new MouseAdapter() { - @Override - public void mouseClicked(MouseEvent mouseEvent) { - if(mouseEvent.getClickCount() >= 2){ - openSelected(); - } - } - }); - jPanel.add(classList); - }); + searchField = new JTextField(); + searchField.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + updateList(); + } - keyEventDispatcher = keyEvent -> { - if(!frame.isVisible()){ - return false; + @Override + public void removeUpdate(DocumentEvent e) { + updateList(); } - if(keyEvent.getKeyCode() == KeyEvent.VK_DOWN){ - int next = classList.isSelectionEmpty() ? 0 : classList.getSelectedIndex() + 1; - classList.setSelectedIndex(next); + + @Override + public void changedUpdate(DocumentEvent e) { + updateList(); } - if(keyEvent.getKeyCode() == KeyEvent.VK_UP){ - int next = classList.isSelectionEmpty() ? classList.getModel().getSize() : classList.getSelectedIndex() - 1; - classList.setSelectedIndex(next); + + }); + searchField.addKeyListener(new KeyAdapter() { + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_DOWN) { + int next = classList.isSelectionEmpty() ? 0 : classList.getSelectedIndex() + 1; + classList.setSelectedIndex(next); + classList.ensureIndexIsVisible(next); + } else if (e.getKeyCode() == KeyEvent.VK_UP) { + int prev = classList.isSelectionEmpty() ? classList.getModel().getSize() : classList.getSelectedIndex() - 1; + classList.setSelectedIndex(prev); + classList.ensureIndexIsVisible(prev); + } else if (e.getKeyCode() == KeyEvent.VK_ESCAPE) { + close(); + } } - if(keyEvent.getKeyCode() == KeyEvent.VK_ENTER){ - openSelected(); + }); + searchField.addActionListener(e -> openSelected()); + contentPane.add(searchField, BorderLayout.NORTH); + + classListModel = new DefaultListModel<>(); + classList = new JList<>(); + classList.setModel(classListModel); + classList.setCellRenderer(new ListCellRendererImpl()); + classList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + classList.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent mouseEvent) { + if (mouseEvent.getClickCount() >= 2) { + int idx = classList.locationToIndex(mouseEvent.getPoint()); + SearchEntryImpl entry = classList.getModel().getElementAt(idx); + openEntry(entry); + } } - if(keyEvent.getKeyCode() == KeyEvent.VK_ESCAPE){ - close(); + }); + contentPane.add(new JScrollPane(classList, JScrollPane.VERTICAL_SCROLLBAR_AS_NEEDED, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER), BorderLayout.CENTER); + + JPanel buttonBar = new JPanel(); + buttonBar.setLayout(new FlowLayout(FlowLayout.RIGHT)); + JButton open = new JButton(I18n.translate("prompt.open")); + open.addActionListener(event -> openSelected()); + buttonBar.add(open); + JButton cancel = new JButton(I18n.translate("prompt.cancel")); + cancel.addActionListener(event -> close()); + buttonBar.add(cancel); + contentPane.add(buttonBar, BorderLayout.SOUTH); + + // apparently the class list doesn't update by itself when the list + // state changes and the dialog is hidden + dialog.addComponentListener(new ComponentAdapter() { + @Override + public void componentShown(ComponentEvent e) { + classList.updateUI(); } - return false; - }; + }); - KeyboardFocusManager.getCurrentKeyboardFocusManager().addKeyEventDispatcher(keyEventDispatcher); + dialog.setContentPane(contentPane); + dialog.setSize(ScaleUtil.getDimension(400, 500)); + dialog.setLocationRelativeTo(parent.getFrame()); + } + + public void show() { + su.clear(); + parent.getController().project.getJarIndex().getEntryIndex().getClasses().parallelStream() + .filter(e -> !e.isInnerClass()) + .map(e -> SearchEntryImpl.from(e, parent.getController())) + .map(SearchUtil.Entry::from) + .sequential() + .forEach(su::add); - frame.setContentPane(pane); - frame.setLayout(new BoxLayout(pane, BoxLayout.Y_AXIS)); + updateList(); - frame.setSize(ScaleUtil.getDimension(360, 500)); - frame.setAlwaysOnTop(true); - frame.setResizable(false); - frame.setLocationRelativeTo(parent.getFrame()); - frame.setVisible(true); - frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE); + searchField.requestFocus(); + searchField.selectAll(); - searchField.requestFocusInWindow(); + dialog.setVisible(true); } - private void openSelected(){ - close(); - if(classList.isSelectionEmpty()){ - return; + private void openSelected() { + SearchEntryImpl selectedValue = classList.getSelectedValue(); + if (selectedValue != null) { + openEntry(selectedValue); } - deobfClasses.stream() - .filter(classEntry -> classEntry.getSimpleName().equals(classList.getSelectedValue())). - findFirst() - .ifPresent(classEntry -> { - parent.getController().navigateTo(classEntry); - parent.getDeobfPanel().deobfClasses.setSelectionClass(classEntry); - }); } - private void close(){ - frame.dispatchEvent(new WindowEvent(frame, WindowEvent.WINDOW_CLOSING)); - KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(keyEventDispatcher); + private void openEntry(SearchEntryImpl e) { + close(); + su.hit(e); + parent.getController().navigateTo(e.obf); + if (e.deobf != null) { + parent.getDeobfPanel().deobfClasses.setSelectionClass(e.deobf); + } else { + parent.getObfPanel().obfClasses.setSelectionClass(e.obf); + } } - private void addRow(JPanel pane, Consumer consumer) { - JPanel panel = new JPanel(new FlowLayout()); - consumer.accept(panel); - pane.add(panel, BorderLayout.CENTER); + private void close() { + dialog.setVisible(false); } - //Updates the list of class names + // Updates the list of class names private void updateList() { - DefaultListModel listModel = new DefaultListModel<>(); + classListModel.clear(); - //Basic search using the Fuzzy libary - //TODO improve on this, to not just work from string and to keep the ClassEntry - List results = FuzzySearch.extractTop(searchField.getText(), deobfClasses.stream().map(ClassEntry::getSimpleName).collect(Collectors.toList()), 25); - results.forEach(extractedResult -> listModel.addElement(extractedResult.getString())); + su.search(searchField.getText()) + .limit(100) + .forEach(classListModel::addElement); + } - classList.setModel(listModel); + public void dispose() { + dialog.dispose(); } + private static final class SearchEntryImpl implements SearchEntry { + + public final ClassEntry obf; + public final ClassEntry deobf; + private SearchEntryImpl(ClassEntry obf, ClassEntry deobf) { + this.obf = obf; + this.deobf = deobf; + } + + @Override + public List getSearchableNames() { + if (deobf != null) { + return Arrays.asList(obf.getSimpleName(), deobf.getSimpleName()); + } else { + return Collections.singletonList(obf.getSimpleName()); + } + } + + @Override + public String getIdentifier() { + return obf.getFullName(); + } + + @Override + public String toString() { + return String.format("SearchEntryImpl { obf: %s, deobf: %s }", obf, deobf); + } + + public static SearchEntryImpl from(ClassEntry e, GuiController controller) { + ClassEntry deobf = controller.project.getMapper().deobfuscate(e); + if (deobf.equals(e)) deobf = null; + return new SearchEntryImpl(e, deobf); + } + + } + + private static final class ListCellRendererImpl extends AbstractListCellRenderer { + + private final JLabel mainName; + private final JLabel secondaryName; + + public ListCellRendererImpl() { + this.setLayout(new BorderLayout()); + + mainName = new JLabel(); + this.add(mainName, BorderLayout.WEST); + + secondaryName = new JLabel(); + secondaryName.setFont(secondaryName.getFont().deriveFont(Font.ITALIC)); + secondaryName.setForeground(Color.GRAY); + this.add(secondaryName, BorderLayout.EAST); + } + + @Override + public void updateUiForEntry(JList list, SearchEntryImpl value, int index, boolean isSelected, boolean cellHasFocus) { + if (value.deobf == null) { + mainName.setText(value.obf.getSimpleName()); + mainName.setToolTipText(value.obf.getFullName()); + secondaryName.setText(""); + secondaryName.setToolTipText(""); + } else { + mainName.setText(value.deobf.getSimpleName()); + mainName.setToolTipText(value.deobf.getFullName()); + secondaryName.setText(value.obf.getSimpleName()); + secondaryName.setToolTipText(value.obf.getFullName()); + } + } + + } } diff --git a/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java b/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java index fd521ab..8098178 100644 --- a/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java +++ b/src/main/java/cuchaz/enigma/gui/elements/MenuBar.java @@ -29,6 +29,16 @@ import cuchaz.enigma.translation.mapping.serde.MappingFormat; import cuchaz.enigma.utils.I18n; import cuchaz.enigma.utils.Pair; +import javax.swing.*; + +import cuchaz.enigma.config.Config; +import cuchaz.enigma.config.Themes; +import cuchaz.enigma.gui.Gui; +import cuchaz.enigma.gui.dialog.AboutDialog; +import cuchaz.enigma.gui.stats.StatsMember; +import cuchaz.enigma.translation.mapping.serde.MappingFormat; +import cuchaz.enigma.utils.I18n; + public class MenuBar extends JMenuBar { public final JMenuItem closeJarMenu; @@ -325,7 +335,7 @@ public class MenuBar extends JMenuBar { menu.add(search); search.addActionListener(event -> { if (this.gui.getController().project != null) { - new SearchDialog(this.gui).show(); + this.gui.getSearchDialog().show(); } }); diff --git a/src/main/java/cuchaz/enigma/gui/panels/PanelEditor.java b/src/main/java/cuchaz/enigma/gui/panels/PanelEditor.java index 8296842..8637afd 100644 --- a/src/main/java/cuchaz/enigma/gui/panels/PanelEditor.java +++ b/src/main/java/cuchaz/enigma/gui/panels/PanelEditor.java @@ -126,7 +126,7 @@ public class PanelEditor extends JEditorPane { public void keyTyped(KeyEvent event) { if (!gui.popupMenu.renameMenu.isEnabled()) return; - if (!event.isControlDown() && !event.isAltDown()) { + if (!event.isControlDown() && !event.isAltDown() && Character.isJavaIdentifierPart(event.getKeyChar())) { EnigmaProject project = gui.getController().project; EntryReference, Entry> reference = project.getMapper().deobfuscate(gui.cursorReference); Entry entry = reference.getNameableEntry(); diff --git a/src/main/java/cuchaz/enigma/gui/util/AbstractListCellRenderer.java b/src/main/java/cuchaz/enigma/gui/util/AbstractListCellRenderer.java new file mode 100644 index 0000000..e071fe1 --- /dev/null +++ b/src/main/java/cuchaz/enigma/gui/util/AbstractListCellRenderer.java @@ -0,0 +1,75 @@ +package cuchaz.enigma.gui.util; + +import java.awt.Component; +import java.awt.event.MouseEvent; + +import javax.swing.*; +import javax.swing.border.Border; + +public abstract class AbstractListCellRenderer extends JPanel implements ListCellRenderer { + + private static final Border NO_FOCUS_BORDER = BorderFactory.createEmptyBorder(1, 1, 1, 1); + + public AbstractListCellRenderer() { + setBorder(getNoFocusBorder()); + } + + protected Border getNoFocusBorder() { + Border border = UIManager.getLookAndFeel().getDefaults().getBorder("List.List.cellNoFocusBorder"); + if (border == null) { + return NO_FOCUS_BORDER; + } + return border; + } + + protected Border getBorder(boolean isSelected, boolean cellHasFocus) { + Border b = null; + if (cellHasFocus) { + UIDefaults defaults = UIManager.getLookAndFeel().getDefaults(); + if (isSelected) { + b = defaults.getBorder("List.focusSelectedCellHighlightBorder"); + } + if (b == null) { + b = defaults.getBorder("List.focusCellHighlightBorder"); + } + } else { + b = getNoFocusBorder(); + } + return b; + } + + public abstract void updateUiForEntry(JList list, E value, int index, boolean isSelected, boolean cellHasFocus); + + @Override + public Component getListCellRendererComponent(JList list, E value, int index, boolean isSelected, boolean cellHasFocus) { + updateUiForEntry(list, value, index, isSelected, cellHasFocus); + + if (isSelected) { + setBackground(list.getSelectionBackground()); + setForeground(list.getSelectionForeground()); + } else { + setBackground(list.getBackground()); + setForeground(list.getForeground()); + } + + setEnabled(list.isEnabled()); + setFont(list.getFont()); + + setBorder(getBorder(isSelected, cellHasFocus)); + + // This isn't the width of the cell, but it's close enough for where it's needed (getComponentAt in getToolTipText) + setSize(list.getWidth(), getPreferredSize().height); + + return this; + } + + @Override + public String getToolTipText(MouseEvent event) { + Component c = getComponentAt(event.getPoint()); + if (c instanceof JComponent) { + return ((JComponent) c).getToolTipText(); + } + return getToolTipText(); + } + +} diff --git a/src/main/java/cuchaz/enigma/gui/util/ScaleUtil.java b/src/main/java/cuchaz/enigma/gui/util/ScaleUtil.java index 8bc826f..9f722e9 100644 --- a/src/main/java/cuchaz/enigma/gui/util/ScaleUtil.java +++ b/src/main/java/cuchaz/enigma/gui/util/ScaleUtil.java @@ -7,7 +7,9 @@ import java.lang.reflect.Field; import java.util.ArrayList; import java.util.List; +import javax.swing.BorderFactory; import javax.swing.UIManager; +import javax.swing.border.Border; import com.github.swingdpi.UiDefaultsScaler; import com.github.swingdpi.plaf.BasicTweaker; @@ -69,6 +71,10 @@ public class ScaleUtil { return (int) (i * getScaleFactor()); } + public static Border createEmptyBorder(int top, int left, int bottom, int right) { + return BorderFactory.createEmptyBorder(scale(top), scale(left), scale(bottom), scale(right)); + } + public static int invert(int i) { return (int) (i / getScaleFactor()); } diff --git a/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java b/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java new file mode 100644 index 0000000..48b255f --- /dev/null +++ b/src/main/java/cuchaz/enigma/utils/search/SearchEntry.java @@ -0,0 +1,17 @@ +package cuchaz.enigma.utils.search; + +import java.util.List; + +public interface SearchEntry { + + List getSearchableNames(); + + /** + * Returns a type that uniquely identifies this search entry across possible changes. + * This is used for tracking the amount of times this entry has been selected. + * + * @return a unique identifier for this search entry + */ + String getIdentifier(); + +} diff --git a/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java b/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java new file mode 100644 index 0000000..e5ed35f --- /dev/null +++ b/src/main/java/cuchaz/enigma/utils/search/SearchUtil.java @@ -0,0 +1,195 @@ +package cuchaz.enigma.utils.search; + +import java.util.*; +import java.util.function.BiFunction; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import cuchaz.enigma.utils.Pair; + +public class SearchUtil { + + private final Map> entries = new HashMap<>(); + private final Map hitCount = new HashMap<>(); + + public void add(T entry) { + Entry e = Entry.from(entry); + entries.put(entry, e); + } + + public void add(Entry entry) { + entries.put(entry.searchEntry, entry); + } + + public void addAll(Collection entries) { + this.entries.putAll(entries.parallelStream().collect(Collectors.toMap(e -> e, Entry::from))); + } + + public void remove(T entry) { + entries.remove(entry); + } + + public void clear() { + entries.clear(); + } + + public void clearHits() { + hitCount.clear(); + } + + public Stream search(String term) { + return entries.values().parallelStream() + .map(e -> new Pair<>(e, e.getScore(term, hitCount.getOrDefault(e.searchEntry.getIdentifier(), 0)))) + .filter(e -> e.b > 0) + .sorted(Comparator.comparingDouble(o -> -o.b)) + .map(e -> e.a.searchEntry) + .sequential(); + } + + public void hit(T entry) { + if (entries.containsKey(entry)) { + hitCount.compute(entry.getIdentifier(), (_id, i) -> i == null ? 1 : i + 1); + } + } + + public static final class Entry { + + public final T searchEntry; + private final String[][] components; + + private Entry(T searchEntry, String[][] components) { + this.searchEntry = searchEntry; + this.components = components; + } + + public float getScore(String term, int hits) { + String ucTerm = term.toUpperCase(Locale.ROOT); + float maxScore = (float) Arrays.stream(components) + .mapToDouble(name -> getScoreFor(ucTerm, name)) + .max().orElse(0.0); + return maxScore * (hits + 1); + } + + /** + * Computes the score for the given name against the given search term. + * + * @param term the search term (expected to be upper-case) + * @param name the entry name, split at word boundaries (see {@link Entry#wordwiseSplit(String)}) + * @return the computed score for the entry + */ + private static float getScoreFor(String term, String[] name) { + int totalLength = Arrays.stream(name).mapToInt(String::length).sum(); + float scorePerChar = 1f / totalLength; + + // This map contains a snapshot of all the states the search has + // been in. The keys are the remaining characters of the search + // term, the values are the maximum scores for that remaining + // search term part. + Map snapshots = new HashMap<>(); + snapshots.put(term, 0f); + + // For each component, start at each existing snapshot, searching + // for the next longest match, and calculate the new score for each + // match length until the maximum. Then the new scores are put back + // into the snapshot map. + for (int componentIndex = 0; componentIndex < name.length; componentIndex++) { + String component = name[componentIndex]; + float posMultiplier = (name.length - componentIndex) * 0.3f; + Map newSnapshots = new HashMap<>(); + for (Map.Entry snapshot : snapshots.entrySet()) { + String remaining = snapshot.getKey(); + float score = snapshot.getValue(); + component = component.toUpperCase(Locale.ROOT); + int l = compareEqualLength(remaining, component); + for (int i = 1; i <= l; i++) { + float baseScore = scorePerChar * i; + float chainBonus = (i - 1) * 0.5f; + merge(newSnapshots, Collections.singletonMap(remaining.substring(i), score + baseScore * posMultiplier + chainBonus), Math::max); + } + } + merge(snapshots, newSnapshots, Math::max); + } + + // Only return the score for when the search term was completely + // consumed. + return snapshots.getOrDefault("", 0f); + } + + private static void merge(Map self, Map source, BiFunction combiner) { + source.forEach((k, v) -> self.compute(k, (_k, v1) -> v1 == null ? v : v == null ? v1 : combiner.apply(v, v1))); + } + + public static Entry from(T e) { + String[][] components = e.getSearchableNames().parallelStream() + .map(Entry::wordwiseSplit) + .toArray(String[][]::new); + return new Entry<>(e, components); + } + + private static int compareEqualLength(String s1, String s2) { + int len = 0; + while (len < s1.length() && len < s2.length() && s1.charAt(len) == s2.charAt(len)) { + len += 1; + } + return len; + } + + /** + * Splits the given input into components, trying to detect word parts. + *

+ * Example of how words get split (using | as seperator): + *

MinecraftClientGame -> Minecraft|Client|Game

+ *

HTTPInputStream -> HTTP|Input|Stream

+ *

class_932 -> class|_|932

+ *

X11FontManager -> X|11|Font|Manager

+ *

openHTTPConnection -> open|HTTP|Connection

+ *

open_http_connection -> open|_|http|_|connection

+ * + * @param input the input to split + * @return the resulting components + */ + private static String[] wordwiseSplit(String input) { + List list = new ArrayList<>(); + while (!input.isEmpty()) { + int take; + if (Character.isLetter(input.charAt(0))) { + if (input.length() == 1) { + take = 1; + } else { + boolean nextSegmentIsUppercase = Character.isUpperCase(input.charAt(0)) && Character.isUpperCase(input.charAt(1)); + if (nextSegmentIsUppercase) { + int nextLowercase = 1; + while (Character.isUpperCase(input.charAt(nextLowercase))) { + nextLowercase += 1; + if (nextLowercase == input.length()) { + nextLowercase += 1; + break; + } + } + take = nextLowercase - 1; + } else { + int nextUppercase = 1; + while (nextUppercase < input.length() && Character.isLowerCase(input.charAt(nextUppercase))) { + nextUppercase += 1; + } + take = nextUppercase; + } + } + } else if (Character.isDigit(input.charAt(0))) { + int nextNonNum = 1; + while (nextNonNum < input.length() && Character.isLetter(input.charAt(nextNonNum)) && !Character.isLowerCase(input.charAt(nextNonNum))) { + nextNonNum += 1; + } + take = nextNonNum; + } else { + take = 1; + } + list.add(input.substring(0, take)); + input = input.substring(take); + } + return list.toArray(new String[0]); + } + + } + +} diff --git a/src/main/resources/lang/en_us.json b/src/main/resources/lang/en_us.json index fe1ac62..a8b3306 100644 --- a/src/main/resources/lang/en_us.json +++ b/src/main/resources/lang/en_us.json @@ -113,6 +113,8 @@ "prompt.close.save": "Save and close", "prompt.close.discard": "Discard changes", "prompt.close.cancel": "Cancel", + "prompt.open": "Open", + "prompt.cancel": "Cancel", "crash.title": "%s - Crash Report", "crash.summary": "%s has crashed! =(", -- cgit v1.2.3