diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..0dd0f59f5
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,126 @@
+# Contributing
+
+Thank you for your interest in contributing to Enigma!
+
+We recommend discussing your contribution with other members of the community - either directly in your pull request,
+or in our other community spaces. We're always happy to help if you need us!
+
+Enigma is distributed under the [LGPL-3.0](LICENSE).
+
+## Translating
+Translations are loaded from [enigma/src/main/resources/lang/](enigma/src/main/resources/lang/).
+
+These are the currently supported languages and their corresponding files:
+
+| Language | File |
+|----------------------------|--------------|
+| English (U.S.) **default** | `en_us.json` |
+| Chinese (Simplified) | `zh_cn.json` |
+| French | `fr_fr.json` |
+| German | `de_de.json` |
+| Japanese | `ja_jp.json` |
+
+If a language you'd like to translate isn't on the list, feel free to ask for help on
+[Quilt's Discord Server](https://discord.quiltmc.org/)!
+
+### Search Aliases
+Many elements in Enigma's GUI support search aliases, but most don't have any aliases.
+A full list of search alias translation keys is [below](#complete-list-of-search-alias-translation-keys).
+Search aliases are alternative names for an element that a user might search for when looking for that element.
+For example, the `Dev` menu element has two search aliases: `"Development"` and `"Debugging"`. This means that if a user
+searches for "Debug", the `Dev` menu will be a search result.
+
+Search aliases are language-specific, so there's no need to translate the English aliases if they aren't likely
+to be searched for in your target language. In fact, any language may add additional aliases that aren't present in the
+English translation.
+
+Since elements can have multiple search aliases, their translations can be lists. Aliases are separated by `;`.
+For example, the `Dev` menu's aliases look like this in the translation file: `"Development;Debugging"`
+This means that aliases may not contain the `;` character.
+
+Some things to keep in mind when adding search aliases:
+- elements' names are always searchable; there's no need to add their names to their aliases
+- searching is case-insensitive, so there's no need to add variations that only differ in capitalization
+- searching matches substrings, so there's no need to add a variation that's a substring of another variation,
+just add the longer variation (note that the element name may be a substring of an alias, as is the case with `Dev`'s
+`"Development"` alias)
+
+If you'd like to add search aliases to an element that doesn't already have aliases, add its alias translation key to
+the translation file.
+
+#### Complete list of search alias translation keys
+| Element | Translation Key |
+|----------------------------------------------------------|----------------------------------------------------|
+| `Dev` menu | `"dev.menu.aliases"` |
+| `Dev`>`Show mapping source plugin` | `"dev.menu.show_mapping_source_plugin.aliases"` |
+| `Dev`>`Debug token highlights` | `"dev.menu.debug_token_highlights.aliases"` |
+| `Dev`>`Log client packets` | `"dev.menu.log_client_packets.aliases"` |
+| `Dev`>`Print mapping tree` | `"dev.menu.print_mapping_tree.aliases"` |
+| `Collab` menu | `"menu.collab.aliases"` |
+| `Collab`>`Connect to Server` | `"menu.collab.connect.aliases"` |
+| `Collab`>`Disconnect` | `"menu.collab.disconnect.aliases"` |
+| `Collab`>`Start Server` | `"menu.collab.server.start.aliases"` |
+| `Collab`>`Stop Server` | `"menu.collab.server.stop.aliases"` |
+| `Decompiler` menu | `"menu.decompiler.aliases"` |
+| `Decompiler`>`Decompiler Settings` | `"menu.decompiler.settings.aliases"` |
+| `Search` menu | `"menu.search.aliases"` |
+| `Search`>`Search All` | `"menu.search.all.aliases"` |
+| `Search`>`Search Classes` | `"menu.search.class.aliases"` |
+| `Search`>`Search Methods` | `"menu.search.method.aliases"` |
+| `Search`>`Search Fields` | `"menu.search.field.aliases"` |
+| `Crash History` menu | `"menu.file.crash_history.aliases"` |
+| `File` menu | `"menu.file.aliases"` |
+| `File`>`Open Jar...` | `"menu.file.jar.open.aliases"` |
+| `File`>`Close Jar` | `"menu.file.jar.close.aliases"` |
+| `File`>`Open Mappings...` | `"menu.file.mappings.open.aliases"` |
+| `File`>`Max Recent Projects` | `"menu.file.max_recent_projects.aliases"` |
+| `File`>`Save Mappings` | `"menu.file.mappings.save.aliases"` |
+| `File`>`Auto Save Mappings` | `"menu.file.mappings.auto_save.aliases"` |
+| `File`>`Close Mappings` | `"menu.file.mappings.close.aliases"` |
+| `File`>`Drop Invalid Mappings` | `"menu.file.mappings.drop.aliases"` |
+| `File`>`Reload Mappings` | `"menu.file.reload_mappings.aliases"` |
+| `File`>`Reload Jar/Mappings` | `"menu.file.reload_all.aliases"` |
+| `File`>`Export Source...` | `"menu.file.export.source.aliases"` |
+| `File`>`Export Jar...` | `"menu.file.export.jar.aliases"` |
+| `File`>`Mapping Stats...` | `"menu.file.stats.aliases"` |
+| `File`>`Configure Keybinds...` | `"menu.file.configure_keybinds.aliases"` |
+| `File`>`Exit` | `"menu.file.exit.aliases"` |
+| `Open Recent Project` menu | `"menu.file.open_recent_project.aliases"` |
+| `Save Mappings As...` menu | `"menu.file.mappings.save_as.aliases"` |
+| `Save Mappings As...`>`Enigma File` | `"enigma:enigma_file.aliases"` |
+| `Save Mappings As...`>`Enigma Directory` | `"enigma:enigma_directory.aliases"` |
+| `Save Mappings As...`>`Enigma ZIP` | `"enigma:enigma_zip.aliases"` |
+| `Save Mappings As...`>`Tiny v2` | `"enigma:tiny_v2.aliases"` |
+| `Save Mappings As...`>`SRG File` | `"enigma:srg_file.aliases"` |
+| `View` menu | `"menu.view.aliases"` |
+| `View`>`Languages` menu | `"menu.view.languages.aliases"` |
+| `View`>`Languages`>`German` | `language.de_de.aliases` |
+| `View`>`Languages`>`English` | `language.en_us.aliases` |
+| `View`>`Languages`>`Français` | `language.fr_fr.aliases` |
+| `View`>`Languages`>`日本語` | `language.ja_jp.aliases` |
+| `View`>`Languages`>`简体中文` | `language.zh_cn.aliases` |
+| `View`>`Server Notifications` menu | `"menu.view.notifications.aliases"` |
+| `View`>`Server Notifications`>`No server notifications` | `"notification.level.none.aliases"` |
+| `View`>`Server Notifications`>`No chat messages` | `"notification.level.no_chat.aliases"` |
+| `View`>`Server Notifications`>`All server notifications` | `"notification.level.full.aliases"` |
+| `View`>`Stat Icons` menu | `"menu.view.stat_icons.aliases"` |
+| `View`>`Stat Icons`>`Include synthetic parameters` | `"menu.view.stat_icons.include_synthetic.aliases"` |
+| `View`>`Stat Icons`>`Count fallback-proposed names` | `"menu.view.stat_icons.count_fallback.aliases"` |
+| `View`>`Stat Icons`>`Enable icons` | `"menu.view.stat_icons.enable_icons.aliases"` |
+| `View`>`Stat Icons`>`Included types` menu | `"menu.view.stat_icons.included_types.aliases"` |
+| `View`>`Stat Icons`>`Included types`>`Methods` | `"type.methods.aliases"` |
+| `View`>`Stat Icons`>`Included types`>`Fields` | `"type.fields.aliases"` |
+| `View`>`Stat Icons`>`Included types`>`Parameters` | `"type.parameters.aliases"` |
+| `View`>`Stat Icons`>`Included types`>`Classes` | `"type.classes.aliases"` |
+| `View`>`Entry Tooltips` menu | `"menu.view.entry_tooltips.aliases"` |
+| `View`>`Entry Tooltips`>`Enable tooltips` | `"menu.view.entry_tooltips.enable.aliases"` |
+| `View`>`Entry Tooltips`>`Allow tooltip interaction` | `"menu.view.entry_tooltips.interactable.aliases"` |
+| `View`>`Themes` menu | `"menu.view.themes.aliases"` |
+| `View`>`Themes`>`Default` | `"menu.view.themes.default.aliases"` |
+| `View`>`Themes`>`Darcula` | `"menu.view.themes.darcula.aliases"` |
+| `View`>`Themes`>`Darcerula` | `"menu.view.themes.darcerula.aliases"` |
+| `View`>`Themes`>`Metal` | `"menu.view.themes.metal.aliases"` |
+| `View`>`Themes`>`System` | `"menu.view.themes.system.aliases"` |
+| `View`>`Themes`>`None (JVM Default)` | `"menu.view.themes.none.aliases"` |
+| `View`>`Scale` menu | `"menu.view.scale.aliases"` |
+| `View`>`Fonts...` | `"menu.view.font.aliases"` |
diff --git a/README.md b/README.md
index 4b694320a..b8fe4d941 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,10 @@
A tool for deobfuscation of Java bytecode. Forked from , originally created by [Jeff Martin](https://www.cuchazinteractive.com/).
+## Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md)
+
## License
Enigma is distributed under the [LGPL-3.0](LICENSE).
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/NotificationManager.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/NotificationManager.java
index 2c67b53cd..d9b511d25 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/NotificationManager.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/NotificationManager.java
@@ -139,8 +139,8 @@ public enum ServerNotificationLevel {
NO_CHAT,
FULL;
- public String getText() {
- return I18n.translate("notification.level." + this.name().toLowerCase());
+ public String getTranslationKey() {
+ return "notification.level." + this.name().toLowerCase();
}
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/Config.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/Config.java
index dbb64fbc3..e38aebd9b 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/Config.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/Config.java
@@ -81,6 +81,9 @@ public final class Config extends ReflectiveConfig {
@Comment("The settings for the statistics window.")
public final StatsSection stats = new StatsSection();
+ @Comment("Settings for the search menus menu.")
+ public final SearchMenusSection searchMenus = new SearchMenusSection();
+
@Comment("You shouldn't enable options in this section unless you know what you're doing!")
public final DevSection development = new DevSection();
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SearchMenusSection.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SearchMenusSection.java
new file mode 100644
index 000000000..091e5ca8c
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/config/SearchMenusSection.java
@@ -0,0 +1,16 @@
+package org.quiltmc.enigma.gui.config;
+
+import org.quiltmc.config.api.ReflectiveConfig;
+import org.quiltmc.config.api.annotations.Comment;
+import org.quiltmc.config.api.annotations.SerializedNameConvention;
+import org.quiltmc.config.api.metadata.NamingSchemes;
+import org.quiltmc.config.api.values.TrackedValue;
+
+@SerializedNameConvention(NamingSchemes.SNAKE_CASE)
+public class SearchMenusSection extends ReflectiveConfig.Section {
+ @Comment("Whether to show the search menus 'view' hint until it's dismissed.")
+ public final TrackedValue showViewHint = this.value(true);
+
+ @Comment("Whether to show the search menus 'choose' hint until it's dismissed.")
+ public final TrackedValue showChooseHint = this.value(true);
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/EnigmaQuickFindToolBar.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/EnigmaQuickFindToolBar.java
index f94872929..ea9139e9b 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/EnigmaQuickFindToolBar.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/dialog/EnigmaQuickFindToolBar.java
@@ -93,8 +93,8 @@ public void translate() {
}
public void reloadKeyBinds() {
- putKeyBindAction(KeyBinds.QUICK_FIND_DIALOG_PREVIOUS, this.searchField, e -> this.prevButton.doClick());
- putKeyBindAction(KeyBinds.QUICK_FIND_DIALOG_NEXT, this.searchField, e -> this.nextButton.doClick());
+ putKeyBindAction(KeyBinds.QUICK_FIND_DIALOG_PREVIOUS, this.searchField, e -> this.prevButton.doClick(0));
+ putKeyBindAction(KeyBinds.QUICK_FIND_DIALOG_NEXT, this.searchField, e -> this.nextButton.doClick(0));
putKeyBindAction(
KeyBinds.QUICK_FIND_DIALOG_CLOSE, this, FocusCondition.WHEN_ANCESTOR_OF_FOCUSED_COMPONENT,
e -> this.setVisible(false)
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
index eda5a880d..7f51b8844 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/docker/CollabDocker.java
@@ -54,8 +54,8 @@ public CollabDocker(Gui gui) {
connectionButtonPanel.add(this.startServerButton, BorderLayout.NORTH);
connectionButtonPanel.add(this.connectToServerButton, BorderLayout.SOUTH);
- this.startServerButton.addActionListener(e -> this.gui.getMenuBar().getCollabMenu().onStartServerClicked());
- this.connectToServerButton.addActionListener(e -> this.gui.getMenuBar().getCollabMenu().onConnectClicked());
+ this.startServerButton.addActionListener(e -> this.gui.getMenuBar().getCollabMenu().onHostClicked());
+ this.connectToServerButton.addActionListener(e -> this.gui.getMenuBar().getCollabMenu().onConnectionClicked());
// we make a copy of the title bar to avoid having to shuffle it around both panels
this.titleCopy = new DockerTitleBar(gui, this, this.titleSupplier);
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java
new file mode 100644
index 000000000..5b8f23492
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/PlaceheldTextField.java
@@ -0,0 +1,197 @@
+package org.quiltmc.enigma.gui.element;
+
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.util.Utils;
+
+import javax.swing.JTextField;
+import javax.swing.text.Document;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Insets;
+
+/**
+ * A text field that displays placeholder text when it's empty.
+ */
+public class PlaceheldTextField extends JTextField {
+ protected static final int DEFAULT_COLUMNS = 0;
+
+ private Placeholder placeholder;
+ @Nullable
+ private Color placeholderColor;
+
+ /**
+ * Constructs a new field with the default {@link Document}, {@value PlaceheldTextField#DEFAULT_COLUMNS} columns,
+ * and no initial text or placeholder.
+ */
+ public PlaceheldTextField() {
+ this(null, null);
+ }
+
+ /**
+ * Constructs a new field with the default {@link Document}, {@value PlaceheldTextField#DEFAULT_COLUMNS} columns,
+ * and the passed initial {@code text} and {@code placeholder}.
+ *
+ * @param text the initial text; may be {@code null}
+ * @param placeholder the initial placeholder; may be {@code null}
+ */
+ public PlaceheldTextField(String text, String placeholder) {
+ this(null, text, placeholder, DEFAULT_COLUMNS);
+ }
+
+ /**
+ * Constructs a new field.
+ *
+ * @param doc see {@link JTextField#JTextField(Document, String, int)}
+ * @param text the initial text; may be {@code null}
+ * @param placeholder the initial placeholder; may be {@code null}
+ * @param columns see {@link JTextField#JTextField(Document, String, int)}
+ *
+ * @exception IllegalArgumentException if {@code columns} is negative
+ */
+ public PlaceheldTextField(Document doc, String text, @Nullable String placeholder, int columns) {
+ super(doc, text, columns);
+ this.setPlaceholder(placeholder);
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ final Dimension size = super.getPreferredSize();
+
+ if (this.placeholder.isFull()) {
+ final Insets insets = this.getInsets();
+
+ size.width = Math.max(insets.left + this.placeholder.getWidth() + insets.right, size.width);
+ }
+
+ return size;
+ }
+
+ @Override
+ protected void paintComponent(Graphics graphics) {
+ super.paintComponent(graphics);
+
+ if (this.placeholder.isFull() && this.getText().isEmpty()) {
+ final Graphics disposableGraphics = graphics.create();
+ GuiUtil.trySetRenderingHints(disposableGraphics);
+
+ Utils.findFirstNonNull(this.placeholderColor, this.getDisabledTextColor(), this.getForeground())
+ .ifPresent(disposableGraphics::setColor);
+ disposableGraphics.setFont(this.getFont());
+
+ final Insets insets = this.getInsets();
+ // HACK to keep the text vertically centered when subclasses adjust preferred height
+ final int extraTop = (this.getPreferredSize().height - super.getPreferredSize().height) / 2;
+ final int baseY = disposableGraphics.getFontMetrics().getMaxAscent() + insets.top + extraTop;
+
+ disposableGraphics.drawString(this.placeholder.getText(), insets.left, baseY);
+
+ disposableGraphics.dispose();
+ }
+ }
+
+ /**
+ * @param placeholder the placeholder text for this field; if {@code null}, no placeholder will be shown
+ */
+ public void setPlaceholder(@Nullable String placeholder) {
+ this.placeholder = placeholder == null || placeholder.isEmpty()
+ ? EmptyPlaceholder.INSTANCE
+ : new FullPlaceholder(placeholder);
+ }
+
+ public String getPlaceholder() {
+ return this.placeholder.getText();
+ }
+
+ protected int getPlaceholderWidth() {
+ return this.placeholder.getWidth();
+ }
+
+ /**
+ * @param color the placeholder color for this field; if {@code null}, the
+ * {@linkplain #getDisabledTextColor() disabled color} will be used
+ */
+ public void setPlaceholderColor(@Nullable Color color) {
+ this.placeholderColor = color;
+ }
+
+ @Nullable
+ public Color getPlaceholderColor() {
+ return this.placeholderColor;
+ }
+
+ @Override
+ public void setFont(Font f) {
+ super.setFont(f);
+
+ // placeholder is null when the super constructor calls setFont
+ if (this.placeholder instanceof FullPlaceholder full) {
+ full.clearWidth();
+ }
+ }
+
+ private sealed interface Placeholder {
+ String getText();
+
+ boolean isFull();
+
+ int getWidth();
+ }
+
+ private static final class EmptyPlaceholder implements Placeholder {
+ static final EmptyPlaceholder INSTANCE = new EmptyPlaceholder();
+
+ @Override
+ public String getText() {
+ return "";
+ }
+
+ @Override
+ public boolean isFull() {
+ return false;
+ }
+
+ @Override
+ public int getWidth() {
+ return 0;
+ }
+ }
+
+ private final class FullPlaceholder implements Placeholder {
+ static final int UNSET_WIDTH = -1;
+
+ final String text;
+
+ int width = UNSET_WIDTH;
+
+ FullPlaceholder(String text) {
+ this.text = text;
+ }
+
+ @Override
+ public String getText() {
+ return this.text;
+ }
+
+ @Override
+ public boolean isFull() {
+ return true;
+ }
+
+ @Override
+ public int getWidth() {
+ if (this.width < 0) {
+ this.width = PlaceheldTextField.this
+ .getFontMetrics(PlaceheldTextField.this.getFont()).stringWidth(this.text);
+ }
+
+ return this.width;
+ }
+
+ public void clearWidth() {
+ this.width = UNSET_WIDTH;
+ }
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
index 472ff03d8..d9f6aff46 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractEnigmaMenu.java
@@ -4,10 +4,10 @@
import javax.swing.JMenu;
-public class AbstractEnigmaMenu extends JMenu implements EnigmaMenu {
+public abstract class AbstractEnigmaMenu extends JMenu implements EnigmaMenu {
protected final Gui gui;
- protected AbstractEnigmaMenu(Gui gui) {
+ public AbstractEnigmaMenu(Gui gui) {
this.gui = gui;
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractSearchableEnigmaMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractSearchableEnigmaMenu.java
new file mode 100644
index 000000000..5e4c68b4b
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/AbstractSearchableEnigmaMenu.java
@@ -0,0 +1,24 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.quiltmc.enigma.gui.Gui;
+
+/**
+ * A base {@link EnigmaMenu} implementation for menus that can appear in {@link SearchMenusMenu} search results.
+ *
+ *
In most cases, children should be {@link SearchableElement}s.
+ */
+public abstract class AbstractSearchableEnigmaMenu extends AbstractEnigmaMenu implements ConventionalSearchableElement {
+ protected AbstractSearchableEnigmaMenu(Gui gui) {
+ super(gui);
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/CollabMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/CollabMenu.java
index 75efc4e6f..081f03d31 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/CollabMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/CollabMenu.java
@@ -1,5 +1,6 @@
package org.quiltmc.enigma.gui.element.menu_bar;
+import org.jspecify.annotations.Nullable;
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.NotificationManager;
@@ -14,40 +15,51 @@
import javax.swing.JOptionPane;
import java.io.IOException;
import java.util.Arrays;
+import java.util.function.Function;
+import java.util.stream.Stream;
-public class CollabMenu extends AbstractEnigmaMenu {
- private final JMenuItem connectItem = new JMenuItem();
- private final JMenuItem startServerItem = new JMenuItem();
+public class CollabMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.collab";
+
+ private final StatefulItem connectionItem = new StatefulItem(state -> state != ConnectionState.CONNECTED
+ ? "menu.collab.connect"
+ : "menu.collab.disconnect"
+ );
+
+ private final StatefulItem hostItem = new StatefulItem(state -> state != ConnectionState.HOSTING
+ ? "menu.collab.server.start"
+ : "menu.collab.server.stop"
+ );
public CollabMenu(Gui gui) {
super(gui);
- this.add(this.connectItem);
- this.add(this.startServerItem);
+ this.add(this.connectionItem);
+ this.add(this.hostItem);
- this.connectItem.addActionListener(e -> this.onConnectClicked());
- this.startServerItem.addActionListener(e -> this.onStartServerClicked());
+ this.connectionItem.addActionListener(e -> this.onConnectionClicked());
+ this.hostItem.addActionListener(e -> this.onHostClicked());
}
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.collab"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
this.retranslate(this.gui.getConnectionState());
}
private void retranslate(ConnectionState state) {
- this.connectItem.setText(I18n.translate(state != ConnectionState.CONNECTED ? "menu.collab.connect" : "menu.collab.disconnect"));
- this.startServerItem.setText(I18n.translate(state != ConnectionState.HOSTING ? "menu.collab.server.start" : "menu.collab.server.stop"));
+ this.connectionItem.retranslate(state);
+ this.hostItem.retranslate(state);
}
@Override
public void updateState(boolean jarOpen, ConnectionState state) {
- this.connectItem.setEnabled(jarOpen && state != ConnectionState.HOSTING);
- this.startServerItem.setEnabled(jarOpen && state != ConnectionState.CONNECTED);
+ this.connectionItem.setEnabled(jarOpen && state != ConnectionState.HOSTING);
+ this.hostItem.setEnabled(jarOpen && state != ConnectionState.CONNECTED);
this.retranslate(state);
}
- public void onConnectClicked() {
+ public void onConnectionClicked() {
if (this.gui.getController().getClient() != null) {
this.gui.getController().disconnectIfConnected(null);
return;
@@ -76,7 +88,7 @@ public void onConnectClicked() {
Arrays.fill(result.password(), (char) 0);
}
- public void onStartServerClicked() {
+ public void onHostClicked() {
if (this.gui.getController().getServer() != null) {
this.gui.getController().disconnectIfConnected(null);
return;
@@ -102,4 +114,44 @@ public void onStartServerClicked() {
this.gui.getController().disconnectIfConnected(null);
}
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
+
+ private static final class StatefulItem extends JMenuItem implements SearchableElement {
+ final Function updateTranslationKey;
+
+ @Nullable
+ String translationKey;
+
+ StatefulItem(Function updateTranslationKey) {
+ this.updateTranslationKey = updateTranslationKey;
+ }
+
+ @Override
+ public Stream streamSearchAliases() {
+ return this.translationKey == null ? Stream.empty() : Stream.concat(
+ Stream.of(this.getSearchName()),
+ SearchableElement.translateExtraAliases(this.translationKey)
+ );
+ }
+
+ void retranslate(ConnectionState state) {
+ this.translationKey = this.updateTranslationKey.apply(state);
+
+ this.setText(I18n.translate(this.translationKey));
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/ConventionalSearchableElement.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/ConventionalSearchableElement.java
new file mode 100644
index 000000000..95badd688
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/ConventionalSearchableElement.java
@@ -0,0 +1,28 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import java.util.stream.Stream;
+
+/**
+ * A {@link SearchableElement} that loads {@linkplain #streamSearchAliases() search aliases} from translations using
+ * {@link #translateExtraAliases(String)}.
+ *
+ *
Aliases are loaded from the translation key formed by appending {@value ALIASES_SUFFIX} to the
+ * {@linkplain #getAliasesTranslationKeyPrefix() key prefix}.
+ */
+public interface ConventionalSearchableElement extends SearchableElement {
+ String ALIASES_SUFFIX = ".aliases";
+
+ @Override
+ default Stream streamSearchAliases() {
+ return Stream.concat(
+ SearchableElement.super.streamSearchAliases(),
+ SearchableElement.translateExtraAliases(this.getAliasesTranslationKeyPrefix() + ALIASES_SUFFIX)
+ );
+ }
+
+ /**
+ * Returns a translation key prefix used to retrieve translatable search aliases.
+ * Usually the prefix is the translation key of the translatable element.
+ */
+ String getAliasesTranslationKeyPrefix();
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DecompilerMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DecompilerMenu.java
index c75985875..b6d897842 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DecompilerMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DecompilerMenu.java
@@ -7,11 +7,12 @@
import org.quiltmc.enigma.util.I18n;
import javax.swing.ButtonGroup;
-import javax.swing.JMenuItem;
import javax.swing.JRadioButtonMenuItem;
-public class DecompilerMenu extends AbstractEnigmaMenu {
- private final JMenuItem decompilerSettingsItem = new JMenuItem();
+public class DecompilerMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.decompiler";
+
+ private final SimpleItem decompilerSettingsItem = new SimpleItem("menu.decompiler.settings");
public DecompilerMenu(Gui gui) {
super(gui);
@@ -19,7 +20,7 @@ public DecompilerMenu(Gui gui) {
ButtonGroup decompilerGroup = new ButtonGroup();
for (Decompiler decompiler : Decompiler.values()) {
- JRadioButtonMenuItem decompilerButton = new JRadioButtonMenuItem(decompiler.name);
+ DecompilerItem decompilerButton = new DecompilerItem(decompiler.name);
decompilerGroup.add(decompilerButton);
if (decompiler.equals(Config.decompiler().activeDecompiler.value())) {
decompilerButton.setSelected(true);
@@ -41,7 +42,28 @@ public DecompilerMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.decompiler"));
- this.decompilerSettingsItem.setText(I18n.translate("menu.decompiler.settings"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
+ this.decompilerSettingsItem.retranslate();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
+
+ private static final class DecompilerItem extends JRadioButtonMenuItem implements SearchableElement {
+ DecompilerItem(String name) {
+ super(name);
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
}
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DevMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DevMenu.java
index 9794fd735..75c86d326 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DevMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/DevMenu.java
@@ -10,10 +10,8 @@
import org.tinylog.Logger;
import javax.swing.JButton;
-import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
-import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
@@ -26,11 +24,17 @@
import java.io.StringWriter;
import java.nio.file.Files;
-public class DevMenu extends AbstractEnigmaMenu {
- private final JCheckBoxMenuItem showMappingSourcePluginItem = new JCheckBoxMenuItem();
- private final JCheckBoxMenuItem debugTokenHighlightsItem = new JCheckBoxMenuItem();
- private final JCheckBoxMenuItem logClientPacketsItem = new JCheckBoxMenuItem();
- private final JMenuItem printMappingTreeItem = new JMenuItem();
+import static org.quiltmc.enigma.gui.util.GuiUtil.syncStateWithConfig;
+
+public class DevMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "dev.menu";
+
+ private final SimpleCheckBoxItem showMappingSourcePluginItem =
+ new SimpleCheckBoxItem("dev.menu.show_mapping_source_plugin");
+ private final SimpleCheckBoxItem debugTokenHighlightsItem =
+ new SimpleCheckBoxItem("dev.menu.debug_token_highlights");
+ private final SimpleCheckBoxItem logClientPacketsItem = new SimpleCheckBoxItem("dev.menu.log_client_packets");
+ private final SimpleItem printMappingTreeItem = new SimpleItem("dev.menu.print_mapping_tree");
public DevMenu(Gui gui) {
super(gui);
@@ -40,6 +44,10 @@ public DevMenu(Gui gui) {
this.add(this.logClientPacketsItem);
this.add(this.printMappingTreeItem);
+ syncStateWithConfig(this.showMappingSourcePluginItem, Config.main().development.showMappingSourcePlugin);
+ syncStateWithConfig(this.debugTokenHighlightsItem, Config.main().development.debugTokenHighlights);
+ syncStateWithConfig(this.logClientPacketsItem, Config.main().development.logClientPackets);
+
this.showMappingSourcePluginItem.addActionListener(e -> this.onShowMappingSourcePluginClicked());
this.debugTokenHighlightsItem.addActionListener(e -> this.onDebugTokenHighlightsClicked());
this.logClientPacketsItem.addActionListener(e -> this.onLogClientPacketsClicked());
@@ -48,21 +56,17 @@ public DevMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText("Dev");
+ this.setText(I18n.translate(TRANSLATION_KEY));
- this.showMappingSourcePluginItem.setText(I18n.translate("dev.menu.show_mapping_source_plugin"));
- this.debugTokenHighlightsItem.setText(I18n.translate("dev.menu.debug_token_highlights"));
- this.logClientPacketsItem.setText(I18n.translate("dev.menu.log_client_packets"));
- this.printMappingTreeItem.setText(I18n.translate("dev.menu.print_mapping_tree"));
+ this.showMappingSourcePluginItem.retranslate();
+ this.debugTokenHighlightsItem.retranslate();
+ this.logClientPacketsItem.retranslate();
+ this.printMappingTreeItem.retranslate();
}
@Override
public void updateState(boolean jarOpen, ConnectionState state) {
this.printMappingTreeItem.setEnabled(jarOpen);
-
- this.showMappingSourcePluginItem.setState(Config.main().development.showMappingSourcePlugin.value());
- this.debugTokenHighlightsItem.setState(Config.main().development.debugTokenHighlights.value());
- this.logClientPacketsItem.setState(Config.main().development.logClientPackets.value());
}
private void showSavableTextAreaDialog(String title, String text, @Nullable String fileName) {
@@ -128,4 +132,9 @@ private void onPrintMappingTreeClicked() {
this.showSavableTextAreaDialog(I18n.translate("dev.mapping_tree"), text.toString(), "mapping_tree.txt");
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/EnigmaMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/EnigmaMenu.java
index 3e1231e76..c9c4875e8 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/EnigmaMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/EnigmaMenu.java
@@ -2,10 +2,13 @@
import org.quiltmc.enigma.gui.ConnectionState;
-public interface EnigmaMenu {
- default void setKeyBinds() {}
+import javax.swing.MenuElement;
- default void updateState(boolean jarOpen, ConnectionState state) {}
+public interface EnigmaMenu extends MenuElement, Retranslatable {
+ default void setKeyBinds() { }
- default void retranslate() {}
+ default void updateState(boolean jarOpen, ConnectionState state) { }
+
+ @Override
+ default void retranslate() { }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/HelpMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/HelpMenu.java
index 6d89d69d6..106434be4 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/HelpMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/HelpMenu.java
@@ -8,24 +8,35 @@
import javax.swing.JMenuItem;
public class HelpMenu extends AbstractEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.help";
+
private final JMenuItem aboutItem = new JMenuItem();
private final JMenuItem githubItem = new JMenuItem();
+ private final SearchMenusMenu searchMenusMenu;
public HelpMenu(Gui gui) {
super(gui);
+ this.searchMenusMenu = new SearchMenusMenu(gui);
+
this.add(this.aboutItem);
this.add(this.githubItem);
+ this.add(this.searchMenusMenu);
this.aboutItem.addActionListener(e -> AboutDialog.show(this.gui.getFrame()));
this.githubItem.addActionListener(e -> this.onGithubClicked());
}
+ public void clearSearchMenusResults() {
+ this.searchMenusMenu.clearLookup();
+ }
+
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.help"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
this.aboutItem.setText(I18n.translate("menu.help.about"));
this.githubItem.setText(I18n.translate("menu.help.github"));
+ this.searchMenusMenu.retranslate();
}
private void onGithubClicked() {
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/MenuBar.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/MenuBar.java
index 934d78a55..1badafe0e 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/MenuBar.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/MenuBar.java
@@ -8,12 +8,14 @@
import java.util.ArrayList;
import java.util.List;
+import java.util.stream.Stream;
public class MenuBar {
private final List menus = new ArrayList<>();
private final CollabMenu collabMenu;
private final FileMenu fileMenu;
+ private final HelpMenu helpMenu;
private final Gui gui;
@@ -25,7 +27,7 @@ public MenuBar(Gui gui) {
ViewMenu viewMenu = new ViewMenu(gui);
SearchMenu searchMenu = new SearchMenu(gui);
this.collabMenu = new CollabMenu(gui);
- HelpMenu helpMenu = new HelpMenu(gui);
+ this.helpMenu = new HelpMenu(gui);
// Enabled with system property "enigma.development" or "--development" flag
DevMenu devMenu = new DevMenu(gui);
@@ -36,7 +38,7 @@ public MenuBar(Gui gui) {
this.addMenu(viewMenu);
this.addMenu(searchMenu);
this.addMenu(this.collabMenu);
- this.addMenu(helpMenu);
+ this.addMenu(this.helpMenu);
if (Boolean.parseBoolean(System.getProperty("enigma.development")) || Config.main().development.anyEnabled) {
this.addMenu(devMenu);
@@ -62,12 +64,20 @@ public void updateUiState() {
for (EnigmaMenu menu : this.menus) {
menu.updateState(jarOpen, connectionState);
}
+
+ this.clearSearchMenusResults();
}
public void retranslateUi() {
for (EnigmaMenu menu : this.menus) {
menu.retranslate();
}
+
+ this.clearSearchMenusResults();
+ }
+
+ public void clearSearchMenusResults() {
+ this.helpMenu.clearSearchMenusResults();
}
public CollabMenu getCollabMenu() {
@@ -77,4 +87,8 @@ public CollabMenu getCollabMenu() {
public FileMenu getFileMenu() {
return this.fileMenu;
}
+
+ public Stream streamMenus() {
+ return this.menus.stream();
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/PlaceheldMenuTextField.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/PlaceheldMenuTextField.java
new file mode 100644
index 000000000..1f354dc46
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/PlaceheldMenuTextField.java
@@ -0,0 +1,192 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.enigma.gui.element.PlaceheldTextField;
+import org.quiltmc.enigma.gui.util.ScaleUtil;
+
+import javax.swing.JMenuItem;
+import javax.swing.MenuElement;
+import javax.swing.MenuSelectionManager;
+import javax.swing.UIManager;
+import javax.swing.border.Border;
+import javax.swing.border.CompoundBorder;
+import javax.swing.border.MatteBorder;
+import javax.swing.text.Document;
+import java.awt.Component;
+import java.awt.Container;
+import java.awt.Dimension;
+import java.awt.Graphics;
+import java.awt.Insets;
+import java.awt.event.KeyEvent;
+import java.awt.event.MouseEvent;
+
+import static org.quiltmc.enigma.gui.util.GuiUtil.EMPTY_MENU_ELEMENTS;
+import static javax.swing.BorderFactory.createCompoundBorder;
+import static javax.swing.BorderFactory.createEmptyBorder;
+
+/**
+ * A {@link PlaceheldTextField} that is also a {@link MenuElement}.
+ *
+ *
Displays an {@linkplain #setSelectionBorder(Border) additional border} when part of the
+ * {@linkplain MenuSelectionManager#getSelectedPath() menu selection}.
+ */
+public class PlaceheldMenuTextField extends PlaceheldTextField implements MenuElement {
+ private static final int DEFAULT_SELECTION_BORDER_LEFT = ScaleUtil.scale(3);
+ private static final int DEFAULT_SELECTION_BORDER_RIGHT = DEFAULT_SELECTION_BORDER_LEFT;
+ private static final int DEFAULT_SELECTION_BORDER_TOP = ScaleUtil.scale(1);
+ private static final int DEFAULT_SELECTION_BORDER_BOTTOM = DEFAULT_SELECTION_BORDER_TOP;
+
+ private CompoundBorder defaultBorder;
+ private CompoundBorder selectionBorder;
+
+ private boolean selectionIncluded;
+
+ private int minHeight = -1;
+
+ /**
+ * @see PlaceheldTextField#PlaceheldTextField() PlaceheldTextField
+ */
+ public PlaceheldMenuTextField() {
+ this(null, null);
+ }
+
+ /**
+ * @see PlaceheldTextField#PlaceheldTextField(String, String) PlaceheldTextField
+ */
+ public PlaceheldMenuTextField(String text, String placeholder) {
+ this(null, text, placeholder, DEFAULT_COLUMNS);
+ }
+
+ /**
+ * @see PlaceheldTextField#PlaceheldTextField(Document, String, String, int) PlaceheldTextField
+ */
+ public PlaceheldMenuTextField(
+ @Nullable Document doc, @Nullable String text, @Nullable String placeholder, int columns
+ ) {
+ super(doc, text, placeholder, columns);
+
+ final Border originalBorder = this.getBorder();
+ this.selectionBorder = createCompoundBorder(
+ new MatteBorder(
+ DEFAULT_SELECTION_BORDER_TOP, DEFAULT_SELECTION_BORDER_LEFT,
+ DEFAULT_SELECTION_BORDER_BOTTOM, DEFAULT_SELECTION_BORDER_RIGHT,
+ UIManager.getColor("MenuItem.selectionBackground")
+ ),
+ originalBorder
+ );
+
+ this.defaultBorder = createCompoundBorder(
+ createEmptyBorder(
+ DEFAULT_SELECTION_BORDER_TOP, DEFAULT_SELECTION_BORDER_LEFT,
+ DEFAULT_SELECTION_BORDER_BOTTOM, DEFAULT_SELECTION_BORDER_RIGHT
+ ),
+ originalBorder
+ );
+
+ super.setBorder(this.defaultBorder);
+ }
+
+ @Override
+ public void setBorder(Border border) {
+ if (this.selectionBorder == null) {
+ // JTextField sets border in constructor before selectionBorder is initialized
+ super.setBorder(border);
+ } else {
+ this.defaultBorder = this.createDefaultBorder(border);
+ this.selectionBorder = createCompoundBorder(this.selectionBorder.getOutsideBorder(), border);
+ super.setBorder(this.defaultBorder);
+ }
+ }
+
+ private CompoundBorder createDefaultBorder(Border border) {
+ final Border selectionOuter = this.selectionBorder.getOutsideBorder();
+ if (selectionOuter == null) {
+ return createCompoundBorder(null, border);
+ } else {
+ final Insets selectionInsets = selectionOuter.getBorderInsets(this);
+ return createCompoundBorder(
+ createEmptyBorder(selectionInsets.top, selectionInsets.left, selectionInsets.bottom, selectionInsets.right),
+ border
+ );
+ }
+ }
+
+ public void setSelectionBorder(Border border) {
+ final Border insideBorder = this.defaultBorder.getInsideBorder();
+ this.selectionBorder = createCompoundBorder(border, insideBorder);
+ this.defaultBorder = this.createDefaultBorder(insideBorder);
+ super.setBorder(this.defaultBorder);
+ }
+
+ @Override
+ protected void paintBorder(Graphics graphics) {
+ if (this.selectionIncluded) {
+ final int leftInset;
+ final int topInset;
+ {
+ final Container parent = this.getParent();
+
+ if (parent == null) {
+ leftInset = 0;
+ topInset = 0;
+ } else {
+ final Insets insets = parent.getInsets();
+ leftInset = insets.left;
+ topInset = insets.top;
+ }
+ }
+
+ this.selectionBorder.paintBorder(
+ this, graphics,
+ this.getX() - leftInset, this.getY() - topInset,
+ this.getWidth(), this.getHeight()
+ );
+ } else {
+ super.paintBorder(graphics);
+ }
+ }
+
+ @Override
+ public void processMouseEvent(MouseEvent event, MenuElement[] path, MenuSelectionManager manager) { }
+
+ @Override
+ public void processKeyEvent(KeyEvent event, MenuElement[] path, MenuSelectionManager manager) { }
+
+ @Override
+ public void menuSelectionChanged(boolean isIncluded) {
+ if (this.selectionIncluded != isIncluded) {
+ this.selectionIncluded = isIncluded;
+ // update border
+ this.repaint();
+ }
+ }
+
+ @Override
+ public MenuElement[] getSubElements() {
+ return EMPTY_MENU_ELEMENTS;
+ }
+
+ @Override
+ public Component getComponent() {
+ return this;
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ final Dimension size = super.getPreferredSize();
+
+ size.height = Math.max(size.height, this.getMinHeight());
+
+ return size;
+ }
+
+ private int getMinHeight() {
+ if (this.minHeight < 0) {
+ // HACK: have at least the height of a menu item
+ // this fixes containing popup menus' positions being off at small scales when this is the only item
+ this.minHeight = new JMenuItem().getPreferredSize().height;
+ }
+
+ return this.minHeight;
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/Retranslatable.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/Retranslatable.java
new file mode 100644
index 000000000..5d935a826
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/Retranslatable.java
@@ -0,0 +1,5 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+public interface Retranslatable {
+ void retranslate();
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenu.java
index 436cb0027..f588308e2 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenu.java
@@ -6,18 +6,24 @@
import org.quiltmc.enigma.gui.util.GuiUtil;
import org.quiltmc.enigma.util.I18n;
-import javax.swing.JMenuItem;
+public class SearchMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.search";
-public class SearchMenu extends AbstractEnigmaMenu {
- private final JMenuItem searchItem = new JMenuItem(GuiUtil.DEOBFUSCATED_ICON);
- private final JMenuItem searchAllItem = new JMenuItem(GuiUtil.DEOBFUSCATED_ICON);
- private final JMenuItem searchClassItem = new JMenuItem(GuiUtil.CLASS_ICON);
- private final JMenuItem searchMethodItem = new JMenuItem(GuiUtil.METHOD_ICON);
- private final JMenuItem searchFieldItem = new JMenuItem(GuiUtil.FIELD_ICON);
+ private final SimpleItem searchItem = new SimpleItem("menu.search");
+ private final SimpleItem searchAllItem = new SimpleItem("menu.search.all");
+ private final SimpleItem searchClassItem = new SimpleItem("menu.search.class");
+ private final SimpleItem searchMethodItem = new SimpleItem("menu.search.method");
+ private final SimpleItem searchFieldItem = new SimpleItem("menu.search.field");
public SearchMenu(Gui gui) {
super(gui);
+ this.searchItem.setIcon(GuiUtil.DEOBFUSCATED_ICON);
+ this.searchAllItem.setIcon(GuiUtil.DEOBFUSCATED_ICON);
+ this.searchClassItem.setIcon(GuiUtil.CLASS_ICON);
+ this.searchMethodItem.setIcon(GuiUtil.METHOD_ICON);
+ this.searchFieldItem.setIcon(GuiUtil.FIELD_ICON);
+
this.add(this.searchItem);
this.add(this.searchAllItem);
this.add(this.searchClassItem);
@@ -33,12 +39,13 @@ public SearchMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.search"));
- this.searchItem.setText(I18n.translate("menu.search"));
- this.searchAllItem.setText(I18n.translate("menu.search.all"));
- this.searchClassItem.setText(I18n.translate("menu.search.class"));
- this.searchMethodItem.setText(I18n.translate("menu.search.method"));
- this.searchFieldItem.setText(I18n.translate("menu.search.field"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
+
+ this.searchItem.retranslate();
+ this.searchAllItem.retranslate();
+ this.searchClassItem.retranslate();
+ this.searchMethodItem.retranslate();
+ this.searchFieldItem.retranslate();
}
@Override
@@ -55,4 +62,9 @@ private void onSearchClicked(boolean clear, SearchDialog.Type... types) {
this.gui.getSearchDialog().show(clear, types);
}
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java
new file mode 100644
index 000000000..87022e6a4
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchMenusMenu.java
@@ -0,0 +1,971 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
+import org.quiltmc.config.api.values.TrackedValue;
+import org.quiltmc.enigma.gui.ConnectionState;
+import org.quiltmc.enigma.gui.Gui;
+import org.quiltmc.enigma.gui.config.Config;
+import org.quiltmc.enigma.gui.util.GridBagConstraintsBuilder;
+import org.quiltmc.enigma.gui.util.GuiUtil;
+import org.quiltmc.enigma.util.I18n;
+import org.quiltmc.enigma.util.multi_trie.CompositeStringMultiTrie;
+import org.quiltmc.enigma.util.multi_trie.EmptyStringMultiTrie;
+import org.quiltmc.enigma.util.multi_trie.MutableStringMultiTrie;
+import org.quiltmc.enigma.util.multi_trie.StringMultiTrie;
+import org.quiltmc.enigma.util.multi_trie.StringMultiTrie.Node;
+import org.tinylog.Logger;
+
+import javax.swing.Box;
+import javax.swing.JButton;
+import javax.swing.JLabel;
+import javax.swing.JMenu;
+import javax.swing.JMenuBar;
+import javax.swing.JMenuItem;
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.MenuElement;
+import javax.swing.MenuSelectionManager;
+import javax.swing.SwingUtilities;
+import javax.swing.border.Border;
+import javax.swing.event.ChangeListener;
+import javax.swing.event.DocumentEvent;
+import javax.swing.event.DocumentListener;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+import java.awt.AWTEvent;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Dimension;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.Point;
+import java.awt.Toolkit;
+import java.awt.Window;
+import java.awt.event.AWTEventListener;
+import java.awt.event.InputEvent;
+import java.awt.event.KeyEvent;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static org.quiltmc.enigma.gui.util.GuiUtil.EMPTY_MENU_ELEMENTS;
+import static org.quiltmc.enigma.util.Utils.getLastOrNull;
+import static javax.swing.BorderFactory.createEmptyBorder;
+
+public class SearchMenusMenu extends AbstractEnigmaMenu {
+ /**
+ * @return a breadth-first stream of the passed {@code root} element and all of its sub-elements,
+ * excluding the {@link HelpMenu} and its sub-elements; the help menu is not searchable because it must be open
+ * to start searching in the first place
+ */
+ private static Stream streamElementTree(MenuElement root) {
+ return root instanceof HelpMenu ? Stream.empty() : Stream.concat(
+ Stream.of(root),
+ Arrays.stream(root.getSubElements()).flatMap(SearchMenusMenu::streamElementTree)
+ );
+ }
+
+ private static void clearSelectionAndChoose(SearchableElement searchable, MenuSelectionManager manager) {
+ // clearing the path ensures:
+ // - the help menu doesn't stay selected if onSearchChosen doesn't set the path
+ // - the current path doesn't interfere with onSearchChosen implementations that set the
+ // path based on the current path
+ manager.clearSelectedPath();
+ searchable.onSearchChosen();
+ }
+
+ private static ImmutableList buildPathTo(MenuElement target) {
+ final List pathBuilder = new LinkedList<>();
+ pathBuilder.add(target);
+ Component element = target.getComponent().getParent();
+ while (true) {
+ if (element instanceof JMenu menu) {
+ pathBuilder.add(0, menu);
+ element = menu.getParent();
+ } else if (element instanceof JPopupMenu popup) {
+ pathBuilder.add(0, popup);
+ element = popup.getInvoker();
+ } else {
+ break;
+ }
+ }
+
+ if (element instanceof JMenuBar bar) {
+ pathBuilder.add(0, bar);
+
+ return ImmutableList.copyOf(pathBuilder);
+ } else {
+ Logger.error(
+ """
+ Failed to build path to %s!
+ \tPath does not begin with menu bar: %s
+ """.formatted(target, pathBuilder)
+ );
+
+ return ImmutableList.of();
+ }
+ }
+
+ @Nullable
+ private final Border defaultPopupBorder;
+
+ private final PlaceheldMenuTextField field = new PlaceheldMenuTextField();
+ private final JMenuItem noResults = new JMenuItem();
+
+ private final HintItem viewHint = new HintItem(
+ "menu.help.search.hint.view",
+ Config.main().searchMenus.showViewHint
+ );
+ private final HintItem chooseHint = new HintItem(
+ "menu.help.search.hint.choose",
+ Config.main().searchMenus.showChooseHint
+ );
+
+ /**
+ * Lazily populated cache.
+ *
+ * @see #getLookup()
+ */
+ @Nullable
+ private Lookup lookup;
+
+ /**
+ * Lazily populated by {@link #getFieldPath()}
+ */
+ @Nullable
+ ImmutableList fieldPath;
+
+ protected SearchMenusMenu(Gui gui) {
+ super(gui);
+
+ // global listener because menu/item key listeners didn't fire
+ // also more reliably clears restorablePath
+ Toolkit.getDefaultToolkit().addAWTEventListener(new KeyHandler(), AWTEvent.KEY_EVENT_MASK);
+
+ this.defaultPopupBorder = this.getPopupMenu().getBorder();
+
+ this.noResults.setEnabled(false);
+ this.noResults.setVisible(false);
+
+ this.viewHint.setVisible(false);
+ this.chooseHint.setVisible(false);
+
+ this.addPermanentChildren();
+
+ MenuSelectionManager.defaultManager().addChangeListener(e -> {
+ if (this.field.isShowing()) {
+ final var manager = (MenuSelectionManager) e.getSource();
+ if (getLastOrNull(manager.getSelectedPath()) == this.getPopupMenu()) {
+ // select here instead of in the hierarchy listener below because:
+ // 1. the manager doesn't report the final path in a hierarchy listener
+ // 2. selecting from the hierarchy listener caused bugs when restoring after showing a result:
+ // - this.field and the restored item both appeared selected, but arrow keys couldn't select
+ // - the final selected path got an increasing number of duplicates of this.field;
+ // none should have been in the path
+ this.selectField(manager);
+ }
+ }
+ });
+
+ // Always focus field, but don't always select its text, because it loses focus when packing new search results.
+ this.field.addHierarchyListener(e -> {
+ if (this.field.isShowing()) {
+ final Window window = SwingUtilities.getWindowAncestor(this.field);
+ if (window != null && window.getType() == Window.Type.POPUP) {
+ // HACK: if PopupFactory::fitsOnScreen is false for light- and medium-weight popups, it makes a
+ // heavy-weight popup instead, whose HeavyWeightWindow component is by default is not focusable.
+ // It prevented this.field from focusing and receiving input.
+ window.setFocusableWindowState(true);
+ }
+
+ this.field.requestFocus();
+ }
+ });
+
+ // select field on content change so shift capitalizes instead of viewing selection
+ this.field.getDocument().addDocumentListener(new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent e) {
+ SearchMenusMenu.this.selectField(MenuSelectionManager.defaultManager());
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent e) {
+ SearchMenusMenu.this.selectField(MenuSelectionManager.defaultManager());
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent e) { }
+ });
+
+ this.getPopupMenu().addPopupMenuListener(new PopupMenuListener() {
+ // menu-select field on caret change so shift capitalizes instead of viewing selection
+ final ChangeListener selectFieldOnCaretChange =
+ e -> SearchMenusMenu.this.selectField(MenuSelectionManager.defaultManager());
+
+ @Override
+ public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+ SearchMenusMenu.this.field.selectAll();
+ // Only listen for text selection after initial text selection (see popupMenuWillBecomeInvisible).
+ SearchMenusMenu.this.field.getCaret().addChangeListener(this.selectFieldOnCaretChange);
+
+ SearchMenusMenu.this.updateResultItems();
+ }
+
+ @Override
+ public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {
+ // Don't listen for text selection before initial text selection so field doesn't get menu-selected
+ // when releasing shift to return from viewing.
+ SearchMenusMenu.this.field.getCaret().removeChangeListener(this.selectFieldOnCaretChange);
+ }
+
+ @Override
+ public void popupMenuCanceled(PopupMenuEvent e) { }
+ });
+
+ this.field.getDocument().addDocumentListener(new DocumentListener() {
+ @Override
+ public void insertUpdate(DocumentEvent e) {
+ SearchMenusMenu.this.updateResultItems();
+ }
+
+ @Override
+ public void removeUpdate(DocumentEvent e) {
+ SearchMenusMenu.this.updateResultItems();
+ }
+
+ @Override
+ public void changedUpdate(DocumentEvent e) {
+ SearchMenusMenu.this.updateResultItems();
+ }
+ });
+
+ this.retranslate();
+ }
+
+ private void selectField(MenuSelectionManager manager) {
+ manager.setSelectedPath(SearchMenusMenu.this.getFieldPath().toArray(EMPTY_MENU_ELEMENTS));
+ }
+
+ private ImmutableList getFieldPath() {
+ if (this.fieldPath == null) {
+ this.fieldPath = buildPathTo(SearchMenusMenu.this.field);
+ }
+
+ return this.fieldPath;
+ }
+
+ private void updateResultItems() {
+ final String searchTerm = this.field.getText();
+
+ final Results results = this.getLookup().search(searchTerm);
+
+ if (results instanceof Results.None) {
+ this.keepOnlyPermanentChildren();
+
+ this.viewHint.setVisible(false);
+ this.chooseHint.setVisible(false);
+
+ this.noResults.setVisible(!searchTerm.isEmpty());
+
+ this.refreshPopup();
+ } else if (results instanceof Results.Different different) {
+ this.keepOnlyPermanentChildren();
+
+ this.noResults.setVisible(different.isEmpty());
+ this.viewHint.configureVisibility();
+ this.chooseHint.configureVisibility();
+
+ different.prefixItems.forEach(this::add);
+
+ if (!different.containingItems.isEmpty()) {
+ if (!different.prefixItems.isEmpty()) {
+ this.add(new JPopupMenu.Separator());
+ }
+
+ different.containingItems.forEach(this::add);
+ }
+
+ this.refreshPopup();
+ } // else Results.Same
+ }
+
+ private void refreshPopup() {
+ if (this.isShowing()) {
+ final JPopupMenu popupMenu = this.getPopupMenu();
+
+ final int oldHeight = popupMenu.getHeight();
+ // HACK: When popups are resizing in limited space, they may remove their borders.
+ // The border won't be restored when re-packing or showing, so manually restore the original border here.
+ popupMenu.setBorder(this.defaultPopupBorder);
+ popupMenu.pack();
+
+ // HACK: re-show if shrinking to move the popup back down in case it had to be shifted up to fit items
+ // re-showing can also result in dropped keystrokes; do so as infrequently as possible
+ // note: the initial showing from JMenu would cause an SOE if we also showed here for the initial showing
+ if (popupMenu.getHeight() < oldHeight && popupMenu.isShowing()) {
+ final Point newOrigin = this.getPopupMenuOrigin();
+ popupMenu.show(this, newOrigin.x, newOrigin.y);
+ }
+ }
+ }
+
+ private void addPermanentChildren() {
+ this.add(this.field);
+ this.add(this.noResults);
+ this.add(this.viewHint);
+ this.add(this.chooseHint);
+ }
+
+ private void keepOnlyPermanentChildren() {
+ this.removeAll();
+ this.addPermanentChildren();
+ }
+
+ private Lookup getLookup() {
+ if (this.lookup == null) {
+ this.lookup = Lookup.build(this.gui);
+ }
+
+ return this.lookup;
+ }
+
+ public void clearLookup() {
+ this.lookup = null;
+ }
+
+ @Override
+ public void updateState(boolean jarOpen, ConnectionState state) {
+ this.clearLookup();
+ }
+
+ @Override
+ public void retranslate() {
+ this.clearLookup();
+
+ this.setText(I18n.translate("menu.help.search"));
+ this.field.setPlaceholder(I18n.translate("menu.help.search.placeholder"));
+ this.noResults.setText(I18n.translate("menu.help.search.no_results"));
+ }
+
+ private static final class Lookup {
+ static final int NON_PREFIX_START = 1;
+ static final int MAX_SUBSTRING_LENGTH = 2;
+
+ final ResultCache emptyCache = new ResultCache(
+ "", EmptyStringMultiTrie.Node.get(),
+ ImmutableMap.of(), ImmutableList.of()
+ );
+
+ static int getCommonPrefixLength(String left, String right) {
+ final int minLength = Math.min(left.length(), right.length());
+
+ for (int i = 0; i < minLength; i++) {
+ if (left.charAt(i) != right.charAt(i)) {
+ return i;
+ }
+ }
+
+ return minLength;
+ }
+
+ static Lookup build(Gui gui) {
+ final CompositeStringMultiTrie prefixBuilder = CompositeStringMultiTrie.createHashed();
+ final CompositeStringMultiTrie containingBuilder =
+ CompositeStringMultiTrie.createHashed();
+ gui.getMenuBar()
+ .streamMenus()
+ .flatMap(SearchMenusMenu::streamElementTree)
+ .mapMulti((element, keep) -> {
+ if (element instanceof SearchableElement searchable) {
+ keep.accept(searchable);
+ }
+ })
+ .forEach(element -> {
+ final Result result = new Result(element);
+
+ final ImmutableMap holders = result.createHolders();
+
+ holders.forEach((lowercaseAlias, holder) -> {
+ prefixBuilder.put(lowercaseAlias, holder);
+
+ final int aliasLength = lowercaseAlias.length();
+ for (int start = NON_PREFIX_START; start < aliasLength; start++) {
+ final int end = Math.min(start + MAX_SUBSTRING_LENGTH, aliasLength);
+ MutableStringMultiTrie.Node node = containingBuilder.getRoot();
+ for (int i = start; i < end; i++) {
+ node = node.next(lowercaseAlias.charAt(i));
+ }
+
+ node.put(holder);
+ }
+ });
+ });
+
+ return new Lookup(prefixBuilder.view(), containingBuilder.view());
+ }
+
+ // maps complete search aliases to their corresponding items
+ final StringMultiTrie holdersByPrefix;
+ // maps all non-prefix MAX_SUBSTRING_LENGTH-length (or less) substrings of search
+ // aliases to their corresponding items; used to narrow down the search scope for substring matches
+ final StringMultiTrie holdersByContaining;
+
+ @NonNull
+ ResultCache resultCache = this.emptyCache;
+
+ Lookup(StringMultiTrie holdersByPrefix, StringMultiTrie holdersByContaining) {
+ this.holdersByPrefix = holdersByPrefix;
+ this.holdersByContaining = holdersByContaining;
+ }
+
+ Results search(String term) {
+ if (term.isEmpty()) {
+ this.resultCache = this.emptyCache;
+
+ return Results.None.INSTANCE;
+ }
+
+ final ResultCache oldCache = this.resultCache;
+ this.resultCache = this.resultCache.updated(term.toLowerCase());
+
+ if (this.resultCache.hasResults()) {
+ if (this.resultCache.hasSameResults(oldCache)) {
+ return Results.Same.INSTANCE;
+ } else {
+ return Results.Different.of(this.resultCache);
+ }
+ } else {
+ return Results.None.INSTANCE;
+ }
+ }
+
+ final class ResultCache {
+ final String term;
+ final Node prefixNode;
+ final ImmutableMap prefixedItemsBySearchable;
+ final ImmutableList containingItems;
+
+ ResultCache(
+ String term, Node prefixNode,
+ ImmutableMap prefixedItemsBySearchable,
+ ImmutableList containingItems
+ ) {
+ this.term = term;
+ this.prefixNode = prefixNode;
+ this.prefixedItemsBySearchable = prefixedItemsBySearchable;
+ this.containingItems = containingItems;
+ }
+
+ boolean hasResults() {
+ return !this.prefixNode.isEmpty() || !this.containingItems.isEmpty();
+ }
+
+ boolean hasSameResults(ResultCache other) {
+ return this == other
+ || this.prefixNode == other.prefixNode
+ && this.containingItems.equals(other.containingItems);
+ }
+
+ ResultCache updated(String term) {
+ if (this.term.isEmpty()) {
+ return this.createFresh(term);
+ } else {
+ final int commonPrefixLength = getCommonPrefixLength(this.term, term);
+ final int termLength = term.length();
+ final int cachedTermLength = this.term.length();
+
+ if (commonPrefixLength == 0) {
+ return this.createFresh(term);
+ } else if (commonPrefixLength == termLength && commonPrefixLength == cachedTermLength) {
+ return this;
+ } else {
+ final int backSteps = cachedTermLength - commonPrefixLength;
+ Node prefixNode = this.prefixNode.previous(backSteps);
+ // true iff this.term is a prefix of term or vice versa
+ final boolean oneTermIsPrefix;
+ if (termLength > commonPrefixLength) {
+ oneTermIsPrefix = backSteps == 0;
+
+ for (int i = commonPrefixLength; i < termLength; i++) {
+ prefixNode = prefixNode.next(term.charAt(i));
+
+ if (prefixNode.isEmpty()) {
+ break;
+ }
+ }
+ } else {
+ oneTermIsPrefix = true;
+ }
+
+ final ImmutableMap prefixedItemsBySearchable;
+ if (oneTermIsPrefix && this.prefixNode.getSize() == prefixNode.getSize()) {
+ prefixedItemsBySearchable = this.prefixedItemsBySearchable;
+ } else {
+ prefixedItemsBySearchable = buildPrefixedItemsBySearchable(prefixNode);
+ }
+
+ final ImmutableList containingItems;
+ if (cachedTermLength == commonPrefixLength && termLength > MAX_SUBSTRING_LENGTH) {
+ containingItems = this.narrowedContainingItemsOf(term);
+ } else {
+ containingItems = this.buildContaining(term, prefixedItemsBySearchable.keySet());
+ }
+
+ return new ResultCache(term, prefixNode, prefixedItemsBySearchable, containingItems);
+ }
+ }
+ }
+
+ ResultCache createFresh(String term) {
+ final Node prefixNode = Lookup.this.holdersByPrefix.get(term);
+ final ImmutableMap prefixedItemsByElement =
+ buildPrefixedItemsBySearchable(prefixNode);
+ return new ResultCache(
+ term, prefixNode,
+ prefixedItemsByElement,
+ this.buildContaining(term, prefixedItemsByElement.keySet())
+ );
+ }
+
+ static ImmutableMap buildPrefixedItemsBySearchable(
+ Node prefixNode
+ ) {
+ return prefixNode
+ .streamValues()
+ .sorted()
+ .collect(toImmutableMap(
+ Result.ItemHolder::getSearchable,
+ Result.ItemHolder::getItem,
+ // if aliases share a prefix, try keeping non-aliased item
+ (left, right) -> right.isSearchNamed() && !left.isSearchNamed() ? right : left
+ ));
+ }
+
+ ImmutableList narrowedContainingItemsOf(String term) {
+ return this.containingItems.stream()
+ .filter(item -> item.getHolder().lowercaseAlias.contains(term))
+ .collect(toImmutableList());
+ }
+
+ ImmutableList buildContaining(String term, Set excluded) {
+ final int termLength = term.length();
+ final boolean longTerm = termLength > MAX_SUBSTRING_LENGTH;
+
+ final Set possibilities = new HashSet<>();
+ final int substringLength = longTerm ? MAX_SUBSTRING_LENGTH : termLength;
+ final int lastSubstringStart = termLength - substringLength;
+ for (int start = 0; start <= lastSubstringStart; start++) {
+ final int end = start + substringLength;
+ Node node = Lookup.this.holdersByContaining.getRoot();
+ for (int i = start; i < end; i++) {
+ node = node.next(term.charAt(i));
+
+ if (node.isEmpty()) {
+ break;
+ }
+ }
+
+ node.streamValues().forEach(possibilities::add);
+ }
+
+ Stream stream = possibilities
+ .stream()
+ .filter(holder -> !excluded.contains(holder.getSearchable()));
+
+ if (longTerm) {
+ stream = stream.filter(holder -> holder.lowercaseAlias.contains(term));
+ }
+
+ return stream
+ .sorted()
+ .map(Result.ItemHolder::getItem)
+ .collect(toImmutableList());
+ }
+ }
+ }
+
+ /**
+ * A wrapper for a {@link SearchableElement}.
+ *
+ *
Its only purpose is to link its (non-static) inner classes to the same {@link SearchableElement}.
+ */
+ private record Result(SearchableElement searchable) {
+ ImmutableMap createHolders() {
+ final String searchName = this.searchable.getSearchName();
+ return this.searchable.streamSearchAliases()
+ .filter(alias -> !alias.isEmpty())
+ .map(alias -> Map.entry(alias.toLowerCase(), alias))
+ .collect(toImmutableMap(
+ Map.Entry::getKey,
+ entry -> new ItemHolder(searchName, entry.getKey(), entry.getValue()),
+ // ignore case-insensitive duplicate aliases
+ (left, right) -> left
+ ));
+ }
+
+ /**
+ * A holder for a {@linkplain #getItem() lazily-created} {@link Item}.
+ *
+ *
Contains the information needed to determine whether its item should be included in results.
+ */
+ class ItemHolder implements Comparable {
+ private final String searchName;
+ private final String alias;
+ final String lowercaseAlias;
+
+ @Nullable
+ Item item;
+
+ ItemHolder(String searchName, String lowercaseAlias, String alias) {
+ this.searchName = searchName;
+ this.lowercaseAlias = lowercaseAlias;
+ this.alias = alias;
+ }
+
+ Item getItem() {
+ if (this.item == null) {
+ this.item = this.alias.equals(this.searchName)
+ ? new Item(this.searchName)
+ : new AliasedItem(this.searchName, this.alias);
+ }
+
+ return this.item;
+ }
+
+ SearchableElement getSearchable() {
+ return Result.this.searchable();
+ }
+
+ Result getResult() {
+ return Result.this;
+ }
+
+ @Override
+ public int compareTo(@NonNull ItemHolder other) {
+ return this.getSearchable().getSearchName().compareTo(other.getSearchable().getSearchName());
+ }
+
+ @Override
+ public int hashCode() {
+ return this.getSearchable().hashCode();
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ return o instanceof ItemHolder other && this.getSearchable() == other.getSearchable();
+ }
+
+ class Item extends JMenuItem {
+ final ImmutableList searchablePath;
+
+ Item(String searchName) {
+ super(searchName);
+
+ this.addActionListener(e -> {
+ clearSelectionAndChoose(Result.this.searchable, MenuSelectionManager.defaultManager());
+ });
+
+ this.searchablePath = buildPathTo(this.getSearchable());
+
+ if (!this.searchablePath.isEmpty()) {
+ final String pathText = this.searchablePath.stream()
+ .flatMap(element -> {
+ if (element instanceof SearchableElement searchableElement) {
+ return Stream.of(searchableElement.getSearchName());
+ } else if (element.getComponent() instanceof JMenuItem menuItem) {
+ return Stream.of(menuItem.getText());
+ } else {
+ // JPopupMenus' names come from their parent JMenus; skip them
+ // JMenuBar has no name
+ if (element instanceof JPopupMenu || element instanceof JMenuBar) {
+ return Stream.empty();
+ } else {
+ Logger.error(
+ "Cannot determine name of menu element in path to %s: %s"
+ .formatted(searchName, element)
+ );
+
+ return Stream.of("???");
+ }
+ }
+ })
+ .collect(Collectors.joining(" > "));
+
+ this.setToolTipText(pathText);
+ }
+ }
+
+ boolean isSearchNamed() {
+ return true;
+ }
+
+ ItemHolder getHolder() {
+ return ItemHolder.this;
+ }
+
+ void selectSearchable(MenuSelectionManager manager) {
+ if (!this.searchablePath.isEmpty()) {
+ manager.setSelectedPath(this.searchablePath.toArray(EMPTY_MENU_ELEMENTS));
+ }
+ }
+
+ SearchableElement getSearchable() {
+ return this.getHolder().getResult().searchable;
+ }
+ }
+
+ class AliasedItem extends Item {
+ static final int UNSET_WIDTH = -1;
+
+ final String alias;
+
+ int aliasWidth = UNSET_WIDTH;
+ @Nullable
+ Font aliasFont;
+
+ AliasedItem(String searchName, String alias) {
+ super(searchName);
+
+ this.alias = alias;
+ }
+
+ @Override
+ boolean isSearchNamed() {
+ return false;
+ }
+
+ @Override
+ public void setFont(Font font) {
+ super.setFont(font);
+
+ this.aliasWidth = UNSET_WIDTH;
+ this.aliasFont = null;
+ }
+
+ @Nullable
+ Font getAliasFont() {
+ if (this.aliasFont == null) {
+ final Font font = this.getFont();
+ if (font != null) {
+ this.aliasFont = font.deriveFont(Font.ITALIC);
+ }
+ }
+
+ return this.aliasFont;
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ final Dimension size = super.getPreferredSize();
+
+ size.width += this.getAliasWidth();
+
+ return size;
+ }
+
+ @Override
+ public void paint(Graphics graphics) {
+ super.paint(graphics);
+
+ final Graphics disposableGraphics = graphics.create();
+ GuiUtil.trySetRenderingHints(disposableGraphics);
+ final Color color = this.getForeground();
+ if (color != null) {
+ disposableGraphics.setColor(color);
+ }
+
+ final Font aliasFont = this.getAliasFont();
+ if (aliasFont != null) {
+ disposableGraphics.setFont(aliasFont);
+ }
+
+ final Insets insets = this.getInsets();
+ final int baseY = disposableGraphics.getFontMetrics().getMaxAscent() + insets.top;
+ disposableGraphics.drawString(this.alias, this.getWidth() - insets.right - this.getAliasWidth(), baseY);
+
+ disposableGraphics.dispose();
+ }
+
+ int getAliasWidth() {
+ if (this.aliasWidth < 0) {
+ this.aliasWidth = this.getFontMetrics(this.getAliasFont()).stringWidth(this.alias);
+ }
+
+ return this.aliasWidth;
+ }
+ }
+ }
+ }
+
+ private sealed interface Results {
+ final class None implements Results {
+ static final None INSTANCE = new None();
+ }
+
+ final class Same implements Results {
+ static final Same INSTANCE = new Same();
+ }
+
+ record Different(
+ ImmutableList prefixItems,
+ ImmutableList containingItems
+ ) implements Results {
+ static Different of(Lookup.ResultCache cache) {
+ return new Different(
+ cache.prefixedItemsBySearchable.values().stream().distinct().collect(toImmutableList()),
+ cache.containingItems.stream().distinct().collect(toImmutableList())
+ );
+ }
+
+ boolean isEmpty() {
+ return this.prefixItems.isEmpty() && this.containingItems.isEmpty();
+ }
+ }
+ }
+
+ private class KeyHandler implements AWTEventListener {
+ static final int PREVIEW_MODIFIER_MASK = InputEvent.SHIFT_DOWN_MASK;
+ static final int PREVIEW_MODIFIER_KEY = KeyEvent.VK_SHIFT;
+
+ @Nullable
+ RestorablePath restorablePath;
+
+ @Override
+ public void eventDispatched(AWTEvent e) {
+ if (e instanceof KeyEvent keyEvent) {
+ if (keyEvent.getID() == KeyEvent.KEY_PRESSED) {
+ final int keyCode = keyEvent.getKeyCode();
+ if (keyCode == PREVIEW_MODIFIER_KEY && keyEvent.getModifiersEx() == PREVIEW_MODIFIER_MASK) {
+ final MenuSelectionManager manager = MenuSelectionManager.defaultManager();
+ final MenuElement[] selectedPath = manager.getSelectedPath();
+
+ final MenuElement selected = getLastOrNull(selectedPath);
+ if (selected != null) {
+ if (selected instanceof Result.ItemHolder.Item item) {
+ SearchMenusMenu.this.viewHint.dismiss();
+
+ this.restorablePath = new RestorablePath(item.getSearchable(), selectedPath);
+
+ item.selectSearchable(manager);
+
+ return;
+ } else if (this.restorablePath != null && this.restorablePath.searched == selected) {
+ return;
+ }
+ }
+ } else if (keyCode == KeyEvent.VK_ENTER) {
+ final int modifiers = keyEvent.getModifiersEx();
+ if (modifiers == 0) {
+ final MenuSelectionManager manager = MenuSelectionManager.defaultManager();
+ if (getLastOrNull(manager.getSelectedPath()) instanceof Result.ItemHolder.Item item) {
+ this.execute(item.getSearchable(), manager);
+ }
+ } else if (modifiers == PREVIEW_MODIFIER_MASK) {
+ if (this.restorablePath != null) {
+ final MenuSelectionManager manager = MenuSelectionManager.defaultManager();
+ if (this.restorablePath.searched == getLastOrNull(manager.getSelectedPath())) {
+ this.execute(this.restorablePath.searched, manager);
+ }
+ }
+ }
+ }
+
+ this.restorablePath = null;
+ } else if (keyEvent.getID() == KeyEvent.KEY_RELEASED) {
+ if (this.restorablePath != null) {
+ if (keyEvent.getKeyCode() == PREVIEW_MODIFIER_KEY) {
+ if (keyEvent.getModifiersEx() == 0) {
+ MenuSelectionManager.defaultManager().setSelectedPath(this.restorablePath.helpPath);
+ this.restorablePath = null;
+ }
+ } else {
+ this.restorablePath = null;
+ }
+ }
+ }
+ }
+ }
+
+ void execute(SearchableElement searchable, MenuSelectionManager manager) {
+ SearchMenusMenu.this.chooseHint.dismiss();
+ clearSelectionAndChoose(searchable, manager);
+ }
+
+ record RestorablePath(SearchableElement searched, MenuElement[] helpPath) { }
+ }
+
+ // not a MenuElement so it can't be selected
+ private class HintItem extends JPanel implements Retranslatable {
+ static final int PAD = 3;
+
+ final String translationKey;
+ final TrackedValue config;
+
+ final JLabel infoIndicator = new JLabel("ⓘ");
+ final JLabel hint = new JLabel();
+ final JButton dismissButton = new JButton("⊗");
+
+ HintItem(String translationKey, TrackedValue config) {
+ this.translationKey = translationKey;
+ this.config = config;
+
+ this.setBorder(createEmptyBorder(0, PAD, 0, PAD));
+
+ this.setLayout(new GridBagLayout());
+
+ this.add(this.infoIndicator);
+
+ final var spacer = Box.createHorizontalBox();
+ spacer.setPreferredSize(new Dimension(PAD, 1));
+ this.add(spacer);
+
+ final Font oldHintFont = this.hint.getFont();
+ this.hint.setFont(oldHintFont.deriveFont(Font.ITALIC, oldHintFont.getSize2D() * 0.85f));
+ this.add(this.hint, GridBagConstraintsBuilder.create()
+ .weightX(1)
+ .fill(GridBagConstraints.HORIZONTAL)
+ .build()
+ );
+
+ this.dismissButton.setBorderPainted(false);
+ this.dismissButton.setBackground(new Color(0, true));
+ this.dismissButton.setMargin(new Insets(0, 0, 0, 0));
+ final Font oldDismissFont = this.dismissButton.getFont();
+ this.dismissButton.setFont(oldDismissFont.deriveFont(oldDismissFont.getSize2D() * 1.3f));
+ this.dismissButton.addActionListener(e -> this.dismiss());
+ this.add(this.dismissButton);
+
+ this.retranslate();
+ }
+
+ void dismiss() {
+ this.config.setValue(false);
+ this.setVisible(false);
+ SearchMenusMenu.this.refreshPopup();
+ }
+
+ void configureVisibility() {
+ this.setVisible(this.config.value());
+ }
+
+ @Override
+ public void retranslate() {
+ this.hint.setText(I18n.translate(this.translationKey));
+ this.dismissButton.setToolTipText(I18n.translate("prompt.dismiss"));
+ }
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchableElement.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchableElement.java
new file mode 100644
index 000000000..3acb56b0c
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SearchableElement.java
@@ -0,0 +1,63 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.AbstractButton;
+import javax.swing.MenuElement;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+/**
+ * A menu element that can appear as a search result in {@link SearchMenusMenu}.
+ *
+ *
All alias translation keys passed to {@link #translateExtraAliases(String)} should be documented in
+ * {@code CONTRIBUTING.md} under
+ * {@code Translating > Search Aliases > Complete list of search alias translation keys}
+ * Alias translations are often absent from language files, so keeping the table populated is essential for
+ * discoverability for translators.
+ *
+ *
{@link ConventionalSearchableElement} adds a convention for obtaining
+ * {@linkplain #streamSearchAliases() search aliases} using {@link #translateExtraAliases(String)}.
+ *
+ * @see SimpleItem
+ * @see SimpleCheckBoxItem
+ * @see SimpleRadioItem
+ */
+public interface SearchableElement extends MenuElement {
+ String ALIAS_DELIMITER = ";";
+
+ /**
+ * Loads a {@value #ALIAS_DELIMITER}-separated list of aliases from the passed {@code translationKey}.
+ *
+ *
Having no entry for {@code translationKey} in a language file is common;
+ * it means the element has no aliases in that language.
+ *
+ * @see ConventionalSearchableElement
+ */
+ static Stream translateExtraAliases(String translationKey) {
+ final String aliases = I18n.translateOrNull(translationKey);
+
+ return aliases == null ? Stream.empty() : Arrays.stream(aliases.split(ALIAS_DELIMITER));
+ }
+
+ /**
+ * @return a finite, ordered {@link Stream} of aliases that this element can be searched by
+ *
+ * @implSpec the {@linkplain #getSearchName() search name} should always be included first
+ * (unless this element is not currently searchable, in which case an empty stream may be returned)
+ */
+ default Stream streamSearchAliases() {
+ return Stream.of(this.getSearchName());
+ }
+
+ /**
+ * @return the name of this element uses in search results; usually {@link AbstractButton#getText()}
+ */
+ String getSearchName();
+
+ /**
+ * Called when this element's search result is chosen.
+ * Most implementations call {@link AbstractButton#doClick(int) doClick(0)}
+ */
+ void onSearchChosen();
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleCheckBoxItem.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleCheckBoxItem.java
new file mode 100644
index 000000000..fe92c3f5f
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleCheckBoxItem.java
@@ -0,0 +1,33 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.JCheckBoxMenuItem;
+
+public class SimpleCheckBoxItem extends JCheckBoxMenuItem implements ConventionalSearchableElement, Retranslatable {
+ private final String translationKey;
+
+ public SimpleCheckBoxItem(String translationKey) {
+ this.translationKey = translationKey;
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate(this.translationKey));
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return this.translationKey;
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleItem.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleItem.java
new file mode 100644
index 000000000..1409adfbf
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleItem.java
@@ -0,0 +1,33 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.JMenuItem;
+
+public class SimpleItem extends JMenuItem implements ConventionalSearchableElement, Retranslatable {
+ private final String translationKey;
+
+ public SimpleItem(String translationKey) {
+ this.translationKey = translationKey;
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate(this.translationKey));
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return this.translationKey;
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleRadioItem.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleRadioItem.java
new file mode 100644
index 000000000..66a0d4d4d
--- /dev/null
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/SimpleRadioItem.java
@@ -0,0 +1,33 @@
+package org.quiltmc.enigma.gui.element.menu_bar;
+
+import org.quiltmc.enigma.util.I18n;
+
+import javax.swing.JRadioButtonMenuItem;
+
+public class SimpleRadioItem extends JRadioButtonMenuItem implements ConventionalSearchableElement, Retranslatable {
+ private final String translationKey;
+
+ public SimpleRadioItem(String translationKey) {
+ this.translationKey = translationKey;
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return this.translationKey;
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate(this.translationKey));
+ }
+}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/CrashHistoryMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/CrashHistoryMenu.java
index 8d41dd61d..6579d70b9 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/CrashHistoryMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/CrashHistoryMenu.java
@@ -3,20 +3,22 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.dialog.CrashDialog;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
import org.quiltmc.enigma.util.I18n;
import javax.swing.ButtonGroup;
import javax.swing.JMenuItem;
-public class CrashHistoryMenu extends AbstractEnigmaMenu {
+public class CrashHistoryMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.file.crash_history";
+
protected CrashHistoryMenu(Gui gui) {
super(gui);
}
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.file.crash_history"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
}
@Override
@@ -40,4 +42,9 @@ public void updateState(boolean jarOpen, ConnectionState state) {
private void onCrashClicked(Throwable throwable) {
CrashDialog.show(throwable, false);
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/FileMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/FileMenu.java
index f0d092bac..cac468fc5 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/FileMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/FileMenu.java
@@ -7,13 +7,14 @@
import org.quiltmc.enigma.gui.config.keybind.KeyBinds;
import org.quiltmc.enigma.gui.dialog.StatsDialog;
import org.quiltmc.enigma.gui.dialog.keybind.ConfigureKeyBindsDialog;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleCheckBoxItem;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleItem;
import org.quiltmc.enigma.gui.util.ExtensionFileFilter;
+import org.quiltmc.enigma.gui.util.GuiUtil;
import org.quiltmc.enigma.util.I18n;
-import javax.swing.JCheckBoxMenuItem;
import javax.swing.JFileChooser;
-import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import java.io.File;
import java.nio.file.Files;
@@ -21,26 +22,28 @@
import java.util.List;
import java.util.Optional;
-public class FileMenu extends AbstractEnigmaMenu {
+public class FileMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.file";
+
private final SaveMappingsAsMenu saveMappingsAs;
private final CrashHistoryMenu crashHistory;
private final OpenRecentMenu openRecent;
- private final JMenuItem jarOpenItem = new JMenuItem();
- private final JMenuItem jarCloseItem = new JMenuItem();
- private final JMenuItem openMappingsItem = new JMenuItem();
- private final JMenuItem maxRecentFilesItem = new JMenuItem();
- private final JMenuItem saveMappingsItem = new JMenuItem();
- private final JCheckBoxMenuItem autoSaveMappingsItem = new JCheckBoxMenuItem();
- private final JMenuItem closeMappingsItem = new JMenuItem();
- private final JMenuItem dropMappingsItem = new JMenuItem();
- private final JMenuItem reloadMappingsItem = new JMenuItem();
- private final JMenuItem reloadAllItem = new JMenuItem();
- private final JMenuItem exportSourceItem = new JMenuItem();
- private final JMenuItem exportJarItem = new JMenuItem();
- private final JMenuItem statsItem = new JMenuItem();
- private final JMenuItem configureKeyBindsItem = new JMenuItem();
- private final JMenuItem exitItem = new JMenuItem();
+ private final SimpleItem jarOpenItem = new SimpleItem("menu.file.jar.open");
+ private final SimpleItem jarCloseItem = new SimpleItem("menu.file.jar.close");
+ private final SimpleItem openMappingsItem = new SimpleItem("menu.file.mappings.open");
+ private final SimpleItem maxRecentFilesItem = new SimpleItem("menu.file.max_recent_projects");
+ private final SimpleItem saveMappingsItem = new SimpleItem("menu.file.mappings.save");
+ private final SimpleCheckBoxItem autoSaveMappingsItem = new SimpleCheckBoxItem("menu.file.mappings.auto_save");
+ private final SimpleItem closeMappingsItem = new SimpleItem("menu.file.mappings.close");
+ private final SimpleItem dropMappingsItem = new SimpleItem("menu.file.mappings.drop");
+ private final SimpleItem reloadMappingsItem = new SimpleItem("menu.file.reload_mappings");
+ private final SimpleItem reloadAllItem = new SimpleItem("menu.file.reload_all");
+ private final SimpleItem exportSourceItem = new SimpleItem("menu.file.export.source");
+ private final SimpleItem exportJarItem = new SimpleItem("menu.file.export.jar");
+ private final SimpleItem statsItem = new SimpleItem("menu.file.stats");
+ private final SimpleItem configureKeyBindsItem = new SimpleItem("menu.file.configure_keybinds");
+ private final SimpleItem exitItem = new SimpleItem("menu.file.exit");
public FileMenu(Gui gui) {
super(gui);
@@ -49,6 +52,8 @@ public FileMenu(Gui gui) {
this.crashHistory = new CrashHistoryMenu(gui);
this.openRecent = new OpenRecentMenu(gui);
+ GuiUtil.syncStateWithConfig(this.autoSaveMappingsItem, Config.editor().autoSaveMappings);
+
this.add(this.jarOpenItem);
this.add(this.jarCloseItem);
this.addSeparator();
@@ -80,7 +85,6 @@ public FileMenu(Gui gui) {
this.jarCloseItem.addActionListener(e -> this.gui.getController().closeJar());
this.maxRecentFilesItem.addActionListener(e -> this.onMaxRecentFilesClicked());
this.saveMappingsItem.addActionListener(e -> this.onSaveMappingsClicked());
- this.autoSaveMappingsItem.addActionListener(e -> Config.editor().autoSaveMappings.setValue(this.autoSaveMappingsItem.getState()));
this.closeMappingsItem.addActionListener(e -> this.onCloseMappingsClicked());
this.dropMappingsItem.addActionListener(e -> this.gui.getController().dropMappings());
this.reloadMappingsItem.addActionListener(e -> this.onReloadMappingsClicked());
@@ -109,7 +113,6 @@ public void updateState(boolean jarOpen, ConnectionState state) {
this.saveMappingsItem.setEnabled(jarOpen && this.gui.mappingsFileChooser.getSelectedFile() != null && this.gui.getConnectionState() != ConnectionState.CONNECTED);
this.saveMappingsAs.updateState();
this.autoSaveMappingsItem.setEnabled(jarOpen);
- this.autoSaveMappingsItem.setState(Config.editor().autoSaveMappings.value());
this.closeMappingsItem.setEnabled(jarOpen);
this.reloadMappingsItem.setEnabled(jarOpen);
this.reloadAllItem.setEnabled(jarOpen);
@@ -122,25 +125,25 @@ public void updateState(boolean jarOpen, ConnectionState state) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.file"));
- this.jarOpenItem.setText(I18n.translate("menu.file.jar.open"));
- this.jarCloseItem.setText(I18n.translate("menu.file.jar.close"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
+ this.jarOpenItem.retranslate();
+ this.jarCloseItem.retranslate();
this.openRecent.retranslate();
- this.maxRecentFilesItem.setText(I18n.translate("menu.file.max_recent_projects"));
- this.openMappingsItem.setText(I18n.translate("menu.file.mappings.open"));
- this.saveMappingsItem.setText(I18n.translate("menu.file.mappings.save"));
+ this.openMappingsItem.retranslate();
+ this.maxRecentFilesItem.retranslate();
+ this.saveMappingsItem.retranslate();
this.saveMappingsAs.retranslate();
- this.autoSaveMappingsItem.setText(I18n.translate("menu.file.mappings.auto_save"));
- this.closeMappingsItem.setText(I18n.translate("menu.file.mappings.close"));
- this.dropMappingsItem.setText(I18n.translate("menu.file.mappings.drop"));
- this.reloadMappingsItem.setText(I18n.translate("menu.file.reload_mappings"));
- this.reloadAllItem.setText(I18n.translate("menu.file.reload_all"));
- this.exportSourceItem.setText(I18n.translate("menu.file.export.source"));
- this.exportJarItem.setText(I18n.translate("menu.file.export.jar"));
- this.statsItem.setText(I18n.translate("menu.file.stats"));
- this.configureKeyBindsItem.setText(I18n.translate("menu.file.configure_keybinds"));
+ this.autoSaveMappingsItem.retranslate();
+ this.closeMappingsItem.retranslate();
+ this.dropMappingsItem.retranslate();
+ this.reloadMappingsItem.retranslate();
+ this.reloadAllItem.retranslate();
+ this.exportSourceItem.retranslate();
+ this.exportJarItem.retranslate();
+ this.statsItem.retranslate();
+ this.configureKeyBindsItem.retranslate();
this.crashHistory.retranslate();
- this.exitItem.setText(I18n.translate("menu.file.exit"));
+ this.exitItem.retranslate();
}
private void onOpenJarClicked() {
@@ -262,4 +265,9 @@ private void onExportJarClicked() {
Config.main().stats.lastSelectedDir.setValue(this.gui.exportJarFileChooser.getCurrentDirectory().getAbsolutePath(), true);
}
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/OpenRecentMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/OpenRecentMenu.java
index ad6142441..88b82e1bb 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/OpenRecentMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/OpenRecentMenu.java
@@ -4,7 +4,7 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
import org.quiltmc.enigma.util.I18n;
import javax.swing.JMenuItem;
@@ -12,14 +12,16 @@
import java.nio.file.Path;
import java.util.List;
-public class OpenRecentMenu extends AbstractEnigmaMenu {
+public class OpenRecentMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.file.open_recent_project";
+
protected OpenRecentMenu(Gui gui) {
super(gui);
}
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.file.open_recent_project"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
}
@Override
@@ -87,4 +89,9 @@ private static Path findCommonPath(Path a, Path b) {
return i != 0 ? a.getRoot().resolve(a.subpath(0, i)) : null;
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/SaveMappingsAsMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/SaveMappingsAsMenu.java
index d70c0c044..7f61e8071 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/SaveMappingsAsMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/file/SaveMappingsAsMenu.java
@@ -4,26 +4,28 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleItem;
import org.quiltmc.enigma.gui.util.ExtensionFileFilter;
import org.quiltmc.enigma.util.I18n;
import javax.swing.JFileChooser;
-import javax.swing.JMenuItem;
import java.io.File;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Consumer;
-public class SaveMappingsAsMenu extends AbstractEnigmaMenu {
- private final Map items = new HashMap<>();
+public class SaveMappingsAsMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.file.mappings.save_as";
+
+ private final Map items = new HashMap<>();
protected SaveMappingsAsMenu(Gui gui) {
super(gui);
this.forEachFormat(format -> {
- JMenuItem item = new JMenuItem();
+ final SimpleItem item = new SimpleItem(format.getId());
this.items.put(format, item);
this.add(item);
@@ -33,9 +35,9 @@ protected SaveMappingsAsMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.file.mappings.save_as"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
- this.forEachFormat(format -> this.items.get(format).setText(I18n.translate(format.getId())));
+ this.items.values().forEach(SimpleItem::retranslate);
}
@Override
@@ -65,4 +67,9 @@ private void forEachFormat(Consumer consumer) {
}
});
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryTooltipsMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryTooltipsMenu.java
index 1598cef8c..1791bc041 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryTooltipsMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/EntryTooltipsMenu.java
@@ -2,20 +2,24 @@
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleCheckBoxItem;
import org.quiltmc.enigma.util.I18n;
-import javax.swing.JCheckBoxMenuItem;
+import static org.quiltmc.enigma.gui.util.GuiUtil.syncStateWithConfig;
-import static org.quiltmc.enigma.gui.util.GuiUtil.createSyncedMenuCheckBox;
+public class EntryTooltipsMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.entry_tooltips";
-public class EntryTooltipsMenu extends AbstractEnigmaMenu {
- private final JCheckBoxMenuItem enable = createSyncedMenuCheckBox(Config.editor().entryTooltips.enable);
- private final JCheckBoxMenuItem interactable = createSyncedMenuCheckBox(Config.editor().entryTooltips.interactable);
+ private final SimpleCheckBoxItem enable = new SimpleCheckBoxItem("menu.view.entry_tooltips.enable");
+ private final SimpleCheckBoxItem interactable = new SimpleCheckBoxItem("menu.view.entry_tooltips.interactable");
protected EntryTooltipsMenu(Gui gui) {
super(gui);
+ syncStateWithConfig(this.enable, Config.editor().entryTooltips.enable);
+ syncStateWithConfig(this.interactable, Config.editor().entryTooltips.interactable);
+
this.add(this.enable);
this.add(this.interactable);
@@ -24,8 +28,13 @@ protected EntryTooltipsMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.entry_tooltips"));
- this.enable.setText(I18n.translate("menu.view.entry_tooltips.enable"));
- this.interactable.setText(I18n.translate("menu.view.entry_tooltips.interactable"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
+ this.enable.retranslate();
+ this.interactable.retranslate();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
}
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/LanguagesMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/LanguagesMenu.java
index 235b51e42..fc56e13d2 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/LanguagesMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/LanguagesMenu.java
@@ -3,7 +3,9 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.ConventionalSearchableElement;
+import org.quiltmc.enigma.gui.element.menu_bar.Retranslatable;
import org.quiltmc.enigma.gui.util.LanguageUtil;
import org.quiltmc.enigma.util.I18n;
@@ -12,15 +14,17 @@
import java.util.HashMap;
import java.util.Map;
-public class LanguagesMenu extends AbstractEnigmaMenu {
- private final Map languages = new HashMap<>();
+public class LanguagesMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.languages";
+
+ private final Map languages = new HashMap<>();
protected LanguagesMenu(Gui gui) {
super(gui);
ButtonGroup languageButtons = new ButtonGroup();
for (String lang : I18n.getAvailableLanguages()) {
- JRadioButtonMenuItem languageButton = new JRadioButtonMenuItem(I18n.getLanguageName(lang));
+ LanguageItem languageButton = new LanguageItem(lang);
this.languages.put(lang, languageButton);
languageButtons.add(languageButton);
this.add(languageButton);
@@ -31,11 +35,9 @@ protected LanguagesMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.languages"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
- for (String lang : I18n.getAvailableLanguages()) {
- this.languages.get(lang).setText(I18n.getLanguageName(lang));
- }
+ this.languages.values().forEach(LanguageItem::retranslate);
}
@Override
@@ -52,4 +54,37 @@ private void onLanguageButtonClicked(String lang) {
I18n.setLanguage(lang);
LanguageUtil.dispatchLanguageChange();
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
+
+ private static final class LanguageItem extends JRadioButtonMenuItem implements ConventionalSearchableElement, Retranslatable {
+ private final String language;
+
+ LanguageItem(String language) {
+ this.language = language;
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.getLanguageName(this.language));
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return "language." + this.language;
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/NotificationsMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/NotificationsMenu.java
index 5247bade4..4562f15f5 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/NotificationsMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/NotificationsMenu.java
@@ -3,25 +3,27 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleRadioItem;
import org.quiltmc.enigma.util.I18n;
import javax.swing.ButtonGroup;
-import javax.swing.JRadioButtonMenuItem;
import java.util.HashMap;
import java.util.Map;
import static org.quiltmc.enigma.gui.NotificationManager.ServerNotificationLevel;
-public class NotificationsMenu extends AbstractEnigmaMenu {
- private final Map buttons = new HashMap<>();
+public class NotificationsMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.notifications";
+
+ private final Map buttons = new HashMap<>();
public NotificationsMenu(Gui gui) {
super(gui);
ButtonGroup buttonGroup = new ButtonGroup();
for (ServerNotificationLevel level : ServerNotificationLevel.values()) {
- JRadioButtonMenuItem notificationsButton = new JRadioButtonMenuItem();
+ SimpleRadioItem notificationsButton = new SimpleRadioItem(level.getTranslationKey());
buttonGroup.add(notificationsButton);
this.buttons.put(level, notificationsButton);
notificationsButton.addActionListener(event -> Config.main().serverNotificationLevel.setValue(level, true));
@@ -31,11 +33,9 @@ public NotificationsMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.notifications"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
- for (ServerNotificationLevel level : ServerNotificationLevel.values()) {
- this.buttons.get(level).setText(level.getText());
- }
+ this.buttons.values().forEach(SimpleRadioItem::retranslate);
}
@Override
@@ -44,4 +44,9 @@ public void updateState(boolean jarOpen, ConnectionState state) {
this.buttons.get(level).setSelected(level.equals(Config.main().serverNotificationLevel.value()));
}
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ScaleMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ScaleMenu.java
index 983331d4e..06d99f7c9 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ScaleMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ScaleMenu.java
@@ -4,7 +4,7 @@
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
import org.quiltmc.enigma.gui.dialog.ChangeDialog;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
import org.quiltmc.enigma.gui.util.ScaleUtil;
import org.quiltmc.enigma.util.I18n;
@@ -17,7 +17,9 @@
import java.util.function.BiConsumer;
import java.util.stream.IntStream;
-public class ScaleMenu extends AbstractEnigmaMenu {
+public class ScaleMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.scale";
+
private final int[] defaultOptions = {100, 125, 150, 175, 200};
private final ButtonGroup optionsGroup = new ButtonGroup();
private final Map options = new HashMap<>();
@@ -49,7 +51,7 @@ protected ScaleMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.scale"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
this.customScaleButton.setText(I18n.translate("menu.view.scale.custom"));
this.forEachDefaultScaleOption((scaleFactor, realFactor) -> this.options.get(realFactor).setText(String.format("%d%%", scaleFactor)));
@@ -97,4 +99,9 @@ private void forEachDefaultScaleOption(BiConsumer consumer) {
option -> consumer.accept(option, option / 100f)
);
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/StatsMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/StatsMenu.java
index 9390bead8..c94610e27 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/StatsMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/StatsMenu.java
@@ -4,23 +4,24 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleCheckBoxItem;
import org.quiltmc.enigma.util.I18n;
import javax.swing.JCheckBoxMenuItem;
-import javax.swing.JMenu;
import javax.swing.SwingUtilities;
import java.util.HashMap;
import java.util.Map;
import static java.util.concurrent.CompletableFuture.runAsync;
-public class StatsMenu extends AbstractEnigmaMenu {
- private final JCheckBoxMenuItem enableIcons = new JCheckBoxMenuItem();
- private final JCheckBoxMenuItem includeSynthetic = new JCheckBoxMenuItem();
- private final JCheckBoxMenuItem countFallback = new JCheckBoxMenuItem();
- private final JMenu statTypes = new JMenu();
- private final Map statTypeItems = new HashMap<>();
+public class StatsMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.stat_icons";
+
+ private final SimpleCheckBoxItem enableIcons = new SimpleCheckBoxItem("menu.view.stat_icons.enable_icons");
+ private final SimpleCheckBoxItem includeSynthetic = new SimpleCheckBoxItem("menu.view.stat_icons.include_synthetic");
+ private final SimpleCheckBoxItem countFallback = new SimpleCheckBoxItem("menu.view.stat_icons.count_fallback");
+ private final TypeMenu typeMenu = new TypeMenu();
public StatsMenu(Gui gui) {
super(gui);
@@ -28,32 +29,21 @@ public StatsMenu(Gui gui) {
this.add(this.enableIcons);
this.add(this.includeSynthetic);
this.add(this.countFallback);
- this.add(this.statTypes);
+ this.add(this.typeMenu);
this.enableIcons.addActionListener(e -> this.onEnableIconsClicked());
this.includeSynthetic.addActionListener(e -> this.onIncludeSyntheticClicked());
this.countFallback.addActionListener(e -> this.onCountFallbackClicked());
- for (StatType statType : StatType.values()) {
- JCheckBoxMenuItem checkbox = new JCheckBoxMenuItem(statType.getName());
- checkbox.addActionListener(event -> this.onCheckboxClicked(statType));
-
- this.statTypeItems.put(statType, checkbox);
- this.statTypes.add(checkbox);
- }
}
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.stat_icons"));
-
- this.enableIcons.setText(I18n.translate("menu.view.stat_icons.enable_icons"));
- this.includeSynthetic.setText(I18n.translate("menu.view.stat_icons.include_synthetic"));
- this.countFallback.setText(I18n.translate("menu.view.stat_icons.count_fallback"));
- this.statTypes.setText(I18n.translate("menu.view.stat_icons.included_types"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
- for (StatType statType : StatType.values()) {
- this.statTypeItems.get(statType).setText(statType.getName());
- }
+ this.enableIcons.retranslate();
+ this.includeSynthetic.retranslate();
+ this.countFallback.retranslate();
+ this.typeMenu.retranslate();
}
@Override
@@ -62,10 +52,7 @@ public void updateState(boolean jarOpen, ConnectionState state) {
this.includeSynthetic.setSelected(Config.main().stats.shouldIncludeSyntheticParameters.value());
this.countFallback.setSelected(Config.main().stats.shouldCountFallbackNames.value());
- for (StatType type : StatType.values()) {
- JCheckBoxMenuItem checkbox = this.statTypeItems.get(type);
- checkbox.setSelected(Config.main().stats.includedStatTypes.value().contains(type));
- }
+ this.typeMenu.updateState(jarOpen, state);
}
private void onEnableIconsClicked() {
@@ -83,19 +70,62 @@ private void onCountFallbackClicked() {
this.updateIconsLater();
}
- private void onCheckboxClicked(StatType type) {
- JCheckBoxMenuItem checkbox = this.statTypeItems.get(type);
+ private void updateIconsLater() {
+ SwingUtilities.invokeLater(() -> runAsync(() -> this.gui.getController().regenerateAndUpdateStatIcons()));
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
+
+ private final class TypeMenu extends AbstractSearchableEnigmaMenu {
+ static final String TRANSLATION_KEY = "menu.view.stat_icons.included_types";
+
+ private final Map items = new HashMap<>();
+
+ TypeMenu() {
+ super(StatsMenu.this.gui);
- if (checkbox.isSelected() && !Config.stats().includedStatTypes.value().contains(type)) {
- Config.stats().includedStatTypes.value().add(type);
- } else {
- Config.stats().includedStatTypes.value().remove(type);
+ for (StatType statType : StatType.values()) {
+ SimpleCheckBoxItem checkbox = new SimpleCheckBoxItem(statType.getTranslationKey());
+ checkbox.addActionListener(event -> this.onTypeClicked(statType));
+
+ this.items.put(statType, checkbox);
+ this.add(checkbox);
+ }
}
- this.updateIconsLater();
- }
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
- private void updateIconsLater() {
- SwingUtilities.invokeLater(() -> runAsync(() -> this.gui.getController().regenerateAndUpdateStatIcons()));
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate(TRANSLATION_KEY));
+
+ this.items.values().forEach(SimpleCheckBoxItem::retranslate);
+ }
+
+ @Override
+ public void updateState(boolean jarOpen, ConnectionState state) {
+ for (StatType type : StatType.values()) {
+ JCheckBoxMenuItem checkbox = this.items.get(type);
+ checkbox.setSelected(Config.main().stats.includedStatTypes.value().contains(type));
+ }
+ }
+
+ void onTypeClicked(StatType type) {
+ JCheckBoxMenuItem checkbox = this.items.get(type);
+
+ if (checkbox.isSelected() && !Config.stats().includedStatTypes.value().contains(type)) {
+ Config.stats().includedStatTypes.value().add(type);
+ } else {
+ Config.stats().includedStatTypes.value().remove(type);
+ }
+
+ StatsMenu.this.updateIconsLater();
+ }
}
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ThemesMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ThemesMenu.java
index 225092c4b..b632921c8 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ThemesMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ThemesMenu.java
@@ -4,7 +4,9 @@
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.config.Config;
import org.quiltmc.enigma.gui.dialog.ChangeDialog;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.ConventionalSearchableElement;
+import org.quiltmc.enigma.gui.element.menu_bar.Retranslatable;
import org.quiltmc.enigma.util.I18n;
import javax.swing.ButtonGroup;
@@ -15,30 +17,30 @@
import static org.quiltmc.enigma.gui.config.Config.ThemeChoice;
-public class ThemesMenu extends AbstractEnigmaMenu {
- private final Map themes = new HashMap<>();
+public class ThemesMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view.themes";
+
+ private final Map themes = new HashMap<>();
protected ThemesMenu(Gui gui) {
super(gui);
ButtonGroup themeGroup = new ButtonGroup();
for (ThemeChoice theme : ThemeChoice.values()) {
- JRadioButtonMenuItem themeButton = new JRadioButtonMenuItem();
- themeGroup.add(themeButton);
- this.themes.put(theme, themeButton);
+ ThemeItem themeItem = new ThemeItem(theme);
+ themeGroup.add(themeItem);
+ this.themes.put(theme, themeItem);
- this.add(themeButton);
- themeButton.addActionListener(e -> this.onThemeClicked(theme));
+ this.add(themeItem);
+ themeItem.addActionListener(e -> this.onThemeClicked(theme));
}
}
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view.themes"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
- for (ThemeChoice theme : ThemeChoice.values()) {
- this.themes.get(theme).setText(I18n.translate("menu.view.themes." + theme.name().toLowerCase(Locale.ROOT)));
- }
+ this.themes.values().forEach(ThemeItem::retranslate);
}
@Override
@@ -54,4 +56,39 @@ private void onThemeClicked(ThemeChoice theme) {
Config.main().theme.setValue(theme, true);
ChangeDialog.show(this.gui.getFrame());
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
+
+ private static final class ThemeItem extends JRadioButtonMenuItem implements ConventionalSearchableElement, Retranslatable {
+ final ThemeChoice theme;
+ final String translationKey;
+
+ private ThemeItem(ThemeChoice theme) {
+ this.theme = theme;
+ this.translationKey = ThemesMenu.TRANSLATION_KEY + "." + theme.name().toLowerCase(Locale.ROOT);
+ }
+
+ @Override
+ public void retranslate() {
+ this.setText(I18n.translate(this.translationKey));
+ }
+
+ @Override
+ public String getSearchName() {
+ return this.getText();
+ }
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return this.translationKey;
+ }
+
+ @Override
+ public void onSearchChosen() {
+ this.doClick(0);
+ }
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
index a9471b18c..5a74c5cd7 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/element/menu_bar/view/ViewMenu.java
@@ -3,12 +3,13 @@
import org.quiltmc.enigma.gui.ConnectionState;
import org.quiltmc.enigma.gui.Gui;
import org.quiltmc.enigma.gui.dialog.FontDialog;
-import org.quiltmc.enigma.gui.element.menu_bar.AbstractEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.AbstractSearchableEnigmaMenu;
+import org.quiltmc.enigma.gui.element.menu_bar.SimpleItem;
import org.quiltmc.enigma.util.I18n;
-import javax.swing.JMenuItem;
+public class ViewMenu extends AbstractSearchableEnigmaMenu {
+ private static final String TRANSLATION_KEY = "menu.view";
-public class ViewMenu extends AbstractEnigmaMenu {
private final StatsMenu stats;
private final NotificationsMenu notifications;
private final LanguagesMenu languages;
@@ -16,7 +17,7 @@ public class ViewMenu extends AbstractEnigmaMenu {
private final ScaleMenu scale;
private final EntryTooltipsMenu entryTooltips;
- private final JMenuItem fontItem = new JMenuItem();
+ private final SimpleItem fontItem = new SimpleItem("menu.view.font");
public ViewMenu(Gui gui) {
super(gui);
@@ -40,7 +41,7 @@ public ViewMenu(Gui gui) {
@Override
public void retranslate() {
- this.setText(I18n.translate("menu.view"));
+ this.setText(I18n.translate(TRANSLATION_KEY));
this.themes.retranslate();
this.notifications.retranslate();
@@ -48,7 +49,7 @@ public void retranslate() {
this.scale.retranslate();
this.stats.retranslate();
this.entryTooltips.retranslate();
- this.fontItem.setText(I18n.translate("menu.view.font"));
+ this.fontItem.retranslate();
}
@Override
@@ -62,4 +63,9 @@ public void updateState(boolean jarOpen, ConnectionState state) {
private void onFontClicked(Gui gui) {
FontDialog.display(gui.getFrame());
}
+
+ @Override
+ public String getAliasesTranslationKeyPrefix() {
+ return TRANSLATION_KEY;
+ }
}
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
index 10d3e993f..aa8f435ec 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/panel/EditorPanel.java
@@ -290,7 +290,8 @@ public void actionPerformed(JTextComponent target, SyntaxDocument sDoc, int dot,
});
this.quickFindToolBar.reloadKeyBinds();
- this.popupMenu.getButtonKeyBinds().forEach((key, button) -> putKeyBindAction(key, this.editor, e -> button.doClick()));
+ this.popupMenu.getButtonKeyBinds()
+ .forEach((key, button) -> putKeyBindAction(key, this.editor, e -> button.doClick(0)));
}
private class TooltipManager {
diff --git a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
index f9e9a973a..0e7827d93 100644
--- a/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
+++ b/enigma-swing/src/main/java/org/quiltmc/enigma/gui/util/GuiUtil.java
@@ -2,6 +2,7 @@
import com.formdev.flatlaf.extras.FlatSVGIcon;
import com.google.common.collect.ImmutableList;
+import org.jspecify.annotations.Nullable;
import org.quiltmc.config.api.values.TrackedValue;
import org.quiltmc.enigma.api.analysis.index.jar.EntryIndex;
import org.quiltmc.enigma.api.service.JarIndexerService;
@@ -28,6 +29,7 @@
import javax.swing.JToolTip;
import javax.swing.JTree;
import javax.swing.KeyStroke;
+import javax.swing.MenuElement;
import javax.swing.Popup;
import javax.swing.PopupFactory;
import javax.swing.Timer;
@@ -41,8 +43,11 @@
import java.awt.Cursor;
import java.awt.Desktop;
import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
import java.awt.MouseInfo;
import java.awt.Point;
+import java.awt.RenderingHints;
import java.awt.Toolkit;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.StringSelection;
@@ -97,6 +102,30 @@ private GuiUtil() {
public static final Icon CHEVRON_UP_WHITE = loadIcon("chevron-up-white");
public static final Icon CHEVRON_DOWN_WHITE = loadIcon("chevron-down-white");
+ public static final MenuElement[] EMPTY_MENU_ELEMENTS = new MenuElement[0];
+
+ private static final String DESKTOP_FONT_HINTS_KEY = "awt.font.desktophints";
+
+ @Nullable
+ private static Map, ?> desktopFontHints = Toolkit.getDefaultToolkit().getDesktopProperty(DESKTOP_FONT_HINTS_KEY)
+ instanceof Map, ?> map ? map : null;
+
+ static {
+ Toolkit.getDefaultToolkit().addPropertyChangeListener(DESKTOP_FONT_HINTS_KEY, e -> {
+ desktopFontHints = e.getNewValue() instanceof Map, ?> map ? map : null;
+ });
+ }
+
+ public static void trySetRenderingHints(Graphics graphics) {
+ if (graphics instanceof Graphics2D graphics2D) {
+ if (desktopFontHints == null) {
+ graphics2D.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ } else {
+ graphics2D.setRenderingHints(desktopFontHints);
+ }
+ }
+ }
+
public static void openUrl(String url) {
try {
if (Objects.requireNonNull(Os.getOs()) == Os.LINUX) {
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
index d68d1b15b..b7dca8203 100644
--- a/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/Utils.java
@@ -2,6 +2,7 @@
import com.google.common.io.CharStreams;
import org.jspecify.annotations.NonNull;
+import org.jspecify.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
@@ -16,6 +17,7 @@
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
+import java.util.Optional;
import java.util.Properties;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
@@ -196,4 +198,32 @@ public static float clamp(double value, float min, float max) {
public static double clamp(double value, double min, double max) {
return Math.min(max, Math.max(value, min));
}
+
+ public static T requireNonNull(T value, String name) {
+ if (value == null) {
+ throw new NullPointerException(name + " must not be null!");
+ } else {
+ return value;
+ }
+ }
+
+ @SafeVarargs
+ public static Optional findFirstNonNull(T... values) {
+ for (final T value : values) {
+ if (value != null) {
+ return Optional.of(value);
+ }
+ }
+
+ return Optional.empty();
+ }
+
+ /**
+ * @return {@code null} if the passed {@code array} is {@code null} or empty,
+ * or the last element of the {@code array} otherwise
+ */
+ @Nullable
+ public static T getLastOrNull(@Nullable T[] array) {
+ return array == null || array.length == 0 ? null : array[array.length - 1];
+ }
}
diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrie.java
new file mode 100644
index 000000000..5bb1ea3c7
--- /dev/null
+++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrie.java
@@ -0,0 +1,278 @@
+package org.quiltmc.enigma.util.multi_trie;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.function.IntFunction;
+import java.util.function.Supplier;
+import java.util.stream.Stream;
+
+/**
+ * A {@link StringMultiTrie} that allows customization of nodes' backing data structures.
+ *
+ * @param the type of values
+ *
+ * @see #of(Supplier, IntFunction)
+ * @see #createHashed()
+ */
+public final class CompositeStringMultiTrie implements MutableStringMultiTrie {
+ private static final int HASHED_NODE_MIN_INITIAL_CAPACITY = 1;
+ private static final int HASHED_ROOT_INITIAL_CAPACITY_POWER = 5;
+ // 32
+ private static final int HASHED_ROOT_INITIAL_CAPACITY =
+ HASHED_NODE_MIN_INITIAL_CAPACITY << HASHED_ROOT_INITIAL_CAPACITY_POWER;
+
+ private final Root root;
+ private final View view = new View();
+
+ /**
+ * Creates a trie with nodes whose branches are held in {@link HashMap}s
+ * and whose leaves are held in {@link HashSet}s.
+ *
+ * @param the type of values stored in the created trie
+ *
+ * @see #of(Supplier, IntFunction)
+ */
+ public static CompositeStringMultiTrie createHashed() {
+ // decrease minimum capacity by a factor of 2 at each depth
+ return of(HashSet::new, depth -> new HashMap<>(depth >= HASHED_ROOT_INITIAL_CAPACITY_POWER
+ ? HASHED_NODE_MIN_INITIAL_CAPACITY
+ : HASHED_ROOT_INITIAL_CAPACITY >> depth
+ ));
+ }
+
+ /**
+ * Creates a trie with nodes whose branches are held in maps created by the passed {@code branchesFactory}
+ * and whose leaves are held in collections created by the passed {@code leavesFactory}.
+ *
+ * @param leavesFactory a pure method that create a new, empty {@link Collection} in which to hold leaf values
+ * @param branchesFactory a pure method that creates a new, empty {@link Map} in which to hold branch nodes;
+ * it receives the depth of the node which may be used to calculate an initial capacity
+ *
+ * @param the type of values stored in the created trie
+ *
+ * @see #createHashed()
+ */
+ public static CompositeStringMultiTrie of(
+ Supplier> leavesFactory,
+ IntFunction