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>> branchesFactory + ) { + return new CompositeStringMultiTrie<>(leavesFactory, branchesFactory); + } + + private CompositeStringMultiTrie( + Supplier> leavesFactory, + IntFunction>> branchesFactory + ) { + this.root = new Root<>( + branchesFactory.apply(Root.DEPTH), leavesFactory.get(), + new Branch.Factory<>(leavesFactory, branchesFactory) + ); + } + + @Override + public Node getRoot() { + return this.root; + } + + @Override + public StringMultiTrie view() { + return this.view; + } + + @VisibleForTesting + static final class Root + extends MutableMapNode> + implements Node { + private static final int DEPTH = 0; + + private final Collection leaves; + private final Map> branches; + + private final CompositeStringMultiTrie.Branch.Factory branchFactory; + + private final NodeView view = new NodeView<>(this); + + private Root( + Map> branches, Collection leaves, + CompositeStringMultiTrie.Branch.Factory branchFactory + ) { + this.leaves = leaves; + this.branches = branches; + this.branchFactory = branchFactory; + } + + @Override + public Node previous() { + return this; + } + + @Override + public Node previous(int steps) { + return this; + } + + @Override + public int getDepth() { + return DEPTH; + } + + @Override + protected CompositeStringMultiTrie.Branch createBranch(Character key) { + return this.branchFactory.create(key, this); + } + + @Override + protected Collection getLeaves() { + return this.leaves; + } + + @Override + protected Map> getBranches() { + return this.branches; + } + + @Override + public StringMultiTrie.Node view() { + return this.view; + } + } + + public static final class Branch extends MutableMapNode.Branch> implements Node { + private final MutableMapNode> parent; + private final Node previous; + + private final Character key; + private final int depth; + + private final Collection leaves; + private final Map> branches; + + private final CompositeStringMultiTrie.Branch.Factory branchFactory; + + private final NodeView view = new NodeView<>(this); + + private

> & Node> Branch( + P parent, char key, + int depth, Collection leaves, Map> branches, + Factory branchFactory + ) { + // two references to the same instance because both its types are necessary + this.parent = parent; + this.previous = parent; + + this.key = key; + this.depth = depth; + + this.leaves = leaves; + this.branches = branches; + this.branchFactory = branchFactory; + } + + @Override + public Node previous() { + return this.previous; + } + + @Override + public Node previous(int steps) { + return MultiTrie.Node.> + previous(this, steps, Node::previous); + } + + @Override + public int getDepth() { + return this.depth; + } + + @Override + protected MutableMapNode> getParent() { + return this.parent; + } + + @Override + protected Character getKey() { + return this.key; + } + + @Override + protected CompositeStringMultiTrie.Branch createBranch(Character key) { + return this.branchFactory.create(key, this); + } + + @Override + protected Collection getLeaves() { + return this.leaves; + } + + @Override + protected Map> getBranches() { + return this.branches; + } + + @Override + public StringMultiTrie.Node view() { + return this.view; + } + + private record Factory( + Supplier> leavesFactory, + IntFunction>> branchesFactory + ) { +

> & Node> + CompositeStringMultiTrie.Branch create(char key, P parent) { + final int depth = parent.getDepth() + 1; + return new CompositeStringMultiTrie.Branch<>( + parent, key, depth, + this.leavesFactory.get(), this.branchesFactory.apply(depth), + this + ); + } + } + } + + private class View extends AbstractView { + @Override + protected CompositeStringMultiTrie getViewed() { + return CompositeStringMultiTrie.this; + } + } + + private static final class NodeView + extends MutableMultiTrie.Node.View + implements StringMultiTrie.Node { + final Node viewed; + + NodeView(Node viewed) { + this.viewed = viewed; + } + + @Override + public StringMultiTrie.Node next(Character key) { + return this.viewed.next(key).view(); + } + + @Override + public Stream> streamNextIgnoreCase(Character key) { + return this.viewed.streamNextIgnoreCase(key); + } + + @Override + public StringMultiTrie.Node previous() { + return this.viewed.previous().view(); + } + + @Override + public StringMultiTrie.Node previous(int steps) { + return this.viewed.previous(steps).view(); + } + + @Override + protected MutableMultiTrie.Node getViewed() { + return this.viewed; + } + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/EmptyStringMultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/EmptyStringMultiTrie.java new file mode 100644 index 000000000..333106bd2 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/EmptyStringMultiTrie.java @@ -0,0 +1,79 @@ +package org.quiltmc.enigma.util.multi_trie; + +import java.util.stream.Stream; + +/** + * An empty, immutable singleton {@link StringMultiTrie}. + */ +public final class EmptyStringMultiTrie implements StringMultiTrie { + private static final EmptyStringMultiTrie INSTANCE = new EmptyStringMultiTrie<>(); + + @SuppressWarnings("unchecked") + public static EmptyStringMultiTrie get() { + return (EmptyStringMultiTrie) INSTANCE; + } + + @Override + public StringMultiTrie.Node getRoot() { + return Node.get(); + } + + @Override + public StringMultiTrie.Node get(String prefix) { + return Node.get(); + } + + @Override + public Stream> streamIgnoreCase(String prefix) { + return Stream.empty(); + } + + /** + * An empty, immutable singleton {@link StringMultiTrie.Node}. + */ + public static final class Node implements StringMultiTrie.Node { + private static final Node INSTANCE = new Node<>(); + + @SuppressWarnings("unchecked") + public static Node get() { + return (Node) INSTANCE; + } + + private Node() { } + + @Override + public Stream streamLeaves() { + return Stream.empty(); + } + + @Override + public Stream streamStems() { + return Stream.empty(); + } + + @Override + public StringMultiTrie.Node next(Character key) { + return this; + } + + @Override + public Stream> streamNextIgnoreCase(Character key) { + return Stream.empty(); + } + + @Override + public StringMultiTrie.Node previous() { + return this; + } + + @Override + public StringMultiTrie.Node previous(int steps) { + return this; + } + + @Override + public int getDepth() { + return 0; + } + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MultiTrie.java new file mode 100644 index 000000000..06898d50c --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MultiTrie.java @@ -0,0 +1,131 @@ +package org.quiltmc.enigma.util.multi_trie; + +import com.google.common.base.Preconditions; + +import java.util.function.UnaryOperator; +import java.util.stream.Stream; + +/** + * A multi-trie (or prefix tree) associates a sequence of keys with one or more values. + * + *

Values can be looked up by a prefix of their key sequence; a {@link Node} holding all values associated with a + * sequence beginning with the prefix will be returned.
+ * The prefix is passed key-by-key to {@link Node#next} starting with {@link #getRoot}. + * + * @param the type of keys + * @param the type of values + */ +public interface MultiTrie { + /** + * The root is the node associated with the empty sequence. + * + *

Other nodes can be looked up via the root. + */ + Node getRoot(); + + /** + * @return the total number of values in this trie + */ + default long getSize() { + return this.getRoot().getSize(); + } + + /** + * @return {@code true} if this trie contains no values, or {@code false} otherwise + */ + default boolean isEmpty() { + return this.getSize() == 0; + } + + /** + * Represents values associated with a prefix in a {@link MultiTrie}. + * + * @param the type of keys + * @param the type of values + */ + interface Node { + static > N previous(N node, int steps, UnaryOperator previous) { + Preconditions.checkArgument(steps >= 0, "steps must not be negative!"); + + if (steps == 0) { + return node; + } + + N prev = previous.apply(node); + while (--steps > 0 && prev.getDepth() > 0) { + prev = previous.apply(prev); + } + + return prev; + } + + /** + * @return a {@link Stream} containing all values with no more keys in their associated sequence;
+ * i.e. the prefix this node is associated with is the whole sequence the values are associated with + */ + Stream streamLeaves(); + + /** + * @return a {@link Stream} containing all values with more keys in their associated sequence;
+ * i.e. the prefix this node is associated with is not + * the whole sequence the values are associated with + */ + Stream streamStems(); + + /** + * @return a {@link Stream} containing all values associated with the prefix this node is associated with + */ + default Stream streamValues() { + return Stream.concat(this.streamLeaves(), this.streamStems()); + } + + /** + * @return the total number of {@linkplain #streamValues() values} associated with this node's prefix + */ + default long getSize() { + return this.streamValues().count(); + } + + /** + * @return {@code true} if this node contains no {@linkplain #streamValues() values}, or {@code false} otherwise + * + * @see #isNonEmpty() + */ + default boolean isEmpty() { + return !this.isNonEmpty(); + } + + /** + * @return {@code false} if this node contains no {@linkplain #streamValues()}, or {@code true} otherwise + * + * @see #isEmpty() + */ + default boolean isNonEmpty() { + return this.getSize() > 0; + } + + /** + * @return the node associated with the sequence formed by appending the passed + * {@code key} to this node's sequence + */ + Node next(K key); + + /** + * @return this node's parent if it is not the {@linkplain #getRoot() root}, otherwise returns this node + */ + Node previous(); + + /** + * Equivalent to chaining {@link #previous()} calls a number of times equal to the passed {@code steps}. + * + * @throws IllegalArgumentException if the passed {@code steps} is negative + */ + Node previous(int steps); + + /** + * @return this node's depth: the length of the prefix it is associated with; the root returns {@code 0}, + * non-roots return positive integers + */ + int getDepth(); + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMapNode.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMapNode.java new file mode 100644 index 000000000..d0b2f81eb --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMapNode.java @@ -0,0 +1,215 @@ +package org.quiltmc.enigma.util.multi_trie; + +import com.google.common.collect.MapMaker; +import org.jspecify.annotations.Nullable; +import org.quiltmc.enigma.util.Utils; +import org.quiltmc.enigma.util.multi_trie.MutableMapNode.Branch; + +import java.util.Collection; +import java.util.Map; +import java.util.stream.Stream; + +/** + * A {@link MutableMultiTrie.Node} that stores branch nodes in a {@link Map}. + * + *

Branch nodes are aware of their keys so that they can help their parents manage their presence in maps for + * trimming of empty nodes and adoption of orphan nodes. + * + * @param the type of keys + * @param the type of values + * @param the type of branch nodes + */ +public abstract class MutableMapNode> implements MutableMultiTrie.Node { + /** + * Orphans are empty nodes. + * + *

They may be moved to {@link #getBranches()} when they become non-empty. + * + * @implNote Keeping orphans in this map ensures there is only ever one node corresponding to a given sequence, + * avoiding any need to merge multiple nodes corresponding to the same sequence.
+ * Using a map with weak value references prevents memory leaks when users look up a sequence with no + * values and don't put any value in it. + */ + private final Map orphans = new MapMaker().weakValues().makeMap(); + + @Override + public Stream streamStems() { + return this.getBranches().values().stream().flatMap(MutableMapNode::streamValues); + } + + @Override + public Stream streamValues() { + return Stream.concat(this.streamLeaves(), this.streamStems()); + } + + /** + * Implementations should be pure (stateless, no side effects). + */ + protected abstract Map getBranches(); + + @Override + public Stream streamLeaves() { + return this.getLeaves().stream(); + } + + @Override + public B next(K key) { + final B next = this.nextBranch(Utils.requireNonNull(key, "key")); + return next == null ? this.orphans.computeIfAbsent(key, this::createBranch) : next; + } + + @Nullable + protected B nextBranch(K key) { + return this.getBranches().get(key); + } + + @Override + public void put(V value) { + this.getLeaves().add(value); + } + + @Override + public boolean removeLeaf(V value) { + return this.getLeaves().remove(value); + } + + @Override + public boolean clearLeaves() { + final boolean hasLeaves = !this.getLeaves().isEmpty(); + if (hasLeaves) { + this.getLeaves().clear(); + return true; + } else { + return false; + } + } + + /** + * Removes the branch node associated with the passed {@code key} if that node is empty. + * + * @implNote This should only be passed one of this node's {@linkplain #getBranches() branches}. + * + * @return {@code true} if the branch was pruned, or {@code false otherwise} + */ + protected boolean pruneIfEmpty(Branch branch) { + if (branch.isEmpty()) { + final K key = branch.getKey(); + final B removed = this.getBranches().remove(key); + if (removed != null) { + // put back in orphans in case a user is still holding a reference + this.orphans.put(key, removed); + } + + return true; + } else { + return false; + } + } + + /** + * If the passed {@code branch} is an {@linkplain #orphans orphan}, + * removes it from {@linkplain #orphans} and puts it in {@linkplain #getBranches() branches}. + * + * @implNote only non-empty branches should be passed to this method; + * it's called when a node may have changed from empty to non-empty + * + * @return {@code true} if the passed {@code branch} was an orphan, or {@code false} otherwise + */ + protected boolean adoptIfOrphan(Branch branch) { + final B orphan = this.orphans.remove(branch.getKey()); + if (orphan != null) { + this.getBranches().put(branch.getKey(), orphan); + + return true; + } else { + return false; + } + } + + /** + * Implementations should be pure (stateless, no side effects). + * + * @return a new, empty branch node instance + */ + protected abstract B createBranch(K key); + + /** + * Implementations should be pure (stateless, no side effects). + */ + protected abstract Collection getLeaves(); + + /** + * A non-root node. + * + *

Adds logic for managing its orphan status and propagating pruning upwards. + * + * @param the type of keys + * @param the type of values + * @param the type of this branch and of this branch's branches + */ + protected abstract static class Branch> extends MutableMapNode { + /** + * Implementations should be pure (stateless, no side effects). + * + * @return this branch's parent; may or may not be another branch node + */ + protected abstract MutableMapNode getParent(); + + /** + * Implementations should be pure (stateless, no side effects). + * + * @return the last key in this branch's sequence; the key this branch's parent stores it under + */ + protected abstract K getKey(); + + @Override + public void put(V value) { + super.put(value); + this.getParent().adoptIfOrphan(this); + } + + @Override + public boolean removeLeaf(V value) { + if (this.getLeaves().remove(value)) { + this.getParent().pruneIfEmpty(this); + + return true; + } else { + return false; + } + } + + @Override + public boolean clearLeaves() { + if (super.clearLeaves()) { + this.getParent().pruneIfEmpty(this); + + return true; + } else { + return false; + } + } + + @Override + protected boolean pruneIfEmpty(Branch branch) { + if (super.pruneIfEmpty(branch)) { + this.getParent().pruneIfEmpty(this); + + return true; + } else { + return false; + } + } + + @Override + protected boolean adoptIfOrphan(Branch branch) { + if (super.adoptIfOrphan(branch)) { + this.getParent().adoptIfOrphan(this); + + return true; + } else { + return false; + } + } + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMultiTrie.java new file mode 100644 index 000000000..525c84a61 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableMultiTrie.java @@ -0,0 +1,84 @@ +package org.quiltmc.enigma.util.multi_trie; + +import java.util.stream.Stream; + +/** + * A multi-trie that allows modification which can also provide unmodifiable views of its contents. + * + * @param the type of keys + * @param the type of values + */ +public interface MutableMultiTrie extends MultiTrie { + @Override + MutableMultiTrie.Node getRoot(); + + /** + * @return a live, unmodifiable view of this trie + */ + MultiTrie view(); + + /** + * A mutable node representing values associated with a {@link MutableMultiTrie}. + * + * @implNote most implementations should remove themselves from any + * backing data structures when the node becomes empty + */ + interface Node extends MultiTrie.Node { + @Override + Node next(K key); + + @Override + Node previous(); + + @Override + Node previous(int steps); + + /** + * @param value a value to add to this node's leaves, associating it with the sequence leading to this node. + */ + void put(V value); + + /** + * @param value a value to remove from this node's leaves + * + * @return {@code true} if a value was removed, or {@code false} otherwise + */ + boolean removeLeaf(V value); + + /** + * Removes all leaves from this node. + * + * @return {@code true} if any values were removed, or {@code false} otherwise + */ + boolean clearLeaves(); + + /** + * @return a live, unmodifiable view of this node + */ + MultiTrie.Node view(); + + abstract class View implements MultiTrie.Node { + @Override + public Stream streamLeaves() { + return this.getViewed().streamLeaves(); + } + + @Override + public Stream streamStems() { + return this.getViewed().streamStems(); + } + + @Override + public Stream streamValues() { + return this.getViewed().streamValues(); + } + + @Override + public int getDepth() { + return this.getViewed().getDepth(); + } + + protected abstract Node getViewed(); + } + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableStringMultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableStringMultiTrie.java new file mode 100644 index 000000000..d3c0820e6 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/MutableStringMultiTrie.java @@ -0,0 +1,150 @@ +package org.quiltmc.enigma.util.multi_trie; + +import org.quiltmc.enigma.util.Utils; + +import java.util.List; +import java.util.stream.Stream; + +/** + * A {@linkplain MutableMultiTrie mutable} {@link StringMultiTrie}. + * + *

Adds {@link String}-specific mutation methods: + *

    + *
  • {@link #put(String, Object)} + *
  • {@link #remove(String, Object)} + *
  • {@link #removeAll(String)} + *
+ * + *

{@linkplain #view() Views} are also {@link StringMultiTrie}s. + * + * @param the type of values + */ +public interface MutableStringMultiTrie extends MutableMultiTrie, StringMultiTrie { + String STRING = "string"; + String VALUE = "value"; + + @Override + Node getRoot(); + + @Override + default Node get(String prefix) { + return StringMultiTrie.get(prefix, this.getRoot(), Node::next); + } + + @Override + default Stream> streamIgnoreCase(String prefix) { + Utils.requireNonNull(prefix, "prefix"); + + if (this.isEmpty()) { + return Stream.empty(); + } + + List> nodes = List.of(this.getRoot().view()); + for (int i = 0; i < prefix.length(); i++) { + final Character key = prefix.charAt(i); + nodes = nodes.stream() + .flatMap(node -> node.streamNextIgnoreCase(key)) + .filter(StringMultiTrie.Node::isNonEmpty) + .toList(); + if (nodes.isEmpty()) { + return Stream.empty(); + } + } + + return nodes.stream(); + } + + @Override + StringMultiTrie view(); + + default Node put(String string, V value) { + Utils.requireNonNull(string, STRING); + Utils.requireNonNull(value, VALUE); + + Node node = this.getRoot(); + for (int i = 0; i < string.length(); i++) { + node = node.next(string.charAt(i)); + } + + node.put(value); + + return node; + } + + default boolean remove(String string, V value) { + Utils.requireNonNull(string, STRING); + Utils.requireNonNull(value, VALUE); + + Node node = this.getRoot(); + for (int i = 0; i < string.length(); i++) { + node = node.next(string.charAt(i)); + + if (node.isEmpty()) { + return false; + } + } + + return node.removeLeaf(value); + } + + default boolean removeAll(String string) { + Utils.requireNonNull(string, STRING); + + Node node = this.getRoot(); + for (int i = 0; i < string.length(); i++) { + node = node.next(string.charAt(i)); + + if (node.isEmpty()) { + return false; + } + } + + return node.clearLeaves(); + } + + interface Node extends StringMultiTrie.Node, MutableMultiTrie.Node { + @Override + Node next(Character key); + + @Override + Node previous(); + + @Override + Node previous(int steps); + + @Override + StringMultiTrie.Node view(); + + @Override + default Stream> streamNextIgnoreCase(Character key) { + final Node next = this.next(key); + return Stream.concat( + next.isEmpty() ? Stream.empty() : Stream.of(next.view()), + StringMultiTrie.tryToggleCase(key) + .map(this::next) + .filter(Node::isNonEmpty) + .map(Node::view) + .stream() + ); + } + } + + abstract class AbstractView implements StringMultiTrie { + @Override + public Node getRoot() { + return this.getViewed().getRoot().view(); + } + + @Override + public Node get(String prefix) { + return this.getViewed().get(prefix).view(); + } + + @Override + public Stream> streamIgnoreCase(String prefix) { + return this.getViewed().streamIgnoreCase(prefix); + } + + protected abstract MutableStringMultiTrie getViewed(); + } +} diff --git a/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/StringMultiTrie.java b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/StringMultiTrie.java new file mode 100644 index 000000000..68d1905f4 --- /dev/null +++ b/enigma/src/main/java/org/quiltmc/enigma/util/multi_trie/StringMultiTrie.java @@ -0,0 +1,72 @@ +package org.quiltmc.enigma.util.multi_trie; + +import org.quiltmc.enigma.util.Utils; + +import java.util.Optional; +import java.util.function.BiFunction; +import java.util.stream.Stream; + +/** + * A {@link MultiTrie} that associates sequences of characters with values of type {@code V}. + * + *

Adds {@link String}/{@link Character}-specific access methods: + *

    + *
  • {@link #get(String)} + *
  • {@link #streamIgnoreCase(String)} + *
  • {@link Node#streamNextIgnoreCase(Character)} + *
+ * + * @param the type of values + */ +public interface StringMultiTrie extends MultiTrie { + static Optional tryToggleCase(char c) { + if (Character.isUpperCase(c)) { + return Optional.of(Character.toLowerCase(c)); + } else if (Character.isLowerCase(c)) { + return Optional.of(Character.toUpperCase(c)); + } else { + return Optional.empty(); + } + } + + static > N get(String prefix, N root, BiFunction next) { + Utils.requireNonNull(prefix, "prefix"); + + N node = root; + for (int i = 0; i < prefix.length(); i++) { + node = next.apply(node, prefix.charAt(i)); + } + + return node; + } + + @Override + Node getRoot(); + + /** + * @return the node associated with the passed {@code prefix} + */ + Node get(String prefix); + + /** + * @return a {@link Stream} of all nodes associated with the passed {@code prefix}, ignoring case + */ + Stream> streamIgnoreCase(String prefix); + + interface Node extends MultiTrie.Node { + @Override + Node next(Character key); + + /** + * @return a stream of 0, 1, or 2 {@linkplain #isNonEmpty() non-empty} nodes associated with the sequence formed + * by appending the passed {@code key} or its case variant to this node's sequence + */ + Stream> streamNextIgnoreCase(Character key); + + @Override + Node previous(); + + @Override + Node previous(int steps); + } +} diff --git a/enigma/src/main/resources/lang/en_us.json b/enigma/src/main/resources/lang/en_us.json index 7fce5ddaa..b7a162ae9 100644 --- a/enigma/src/main/resources/lang/en_us.json +++ b/enigma/src/main/resources/lang/en_us.json @@ -1,6 +1,10 @@ { "language": "English", + "language.fr_fr.aliases": "French", + "language.ja_jp.aliases": "Japanese", + "language.zh_cn.aliases": "Simplified Chinese", + "enigma:enigma_file": "Enigma File", "enigma:enigma_directory": "Enigma Directory", "enigma:enigma_zip": "Enigma ZIP", @@ -67,12 +71,18 @@ "menu.view": "View", "menu.view.notifications": "Server Notifications", "menu.view.themes": "Themes", + "menu.view.themes.aliases": "Skins", "menu.view.themes.default": "Default", + "menu.view.themes.default.aliases": "Light", "menu.view.themes.darcula": "Darcula", + "menu.view.themes.darcula.aliases": "Dark;Dracula", "menu.view.themes.darcerula": "Darcerula", + "menu.view.themes.darcerula.aliases": "Darker;Dracula", "menu.view.themes.metal": "Metal", "menu.view.themes.system": "System", + "menu.view.themes.system.aliases": "OS;Operating System", "menu.view.themes.none": "None (JVM Default)", + "menu.view.themes.none.aliases": "Java", "menu.view.languages": "Languages", "menu.view.scale": "Scale", "menu.view.scale.custom": "Custom...", @@ -95,6 +105,7 @@ "menu.search.field": "Search Fields", "menu.search.only_exact_matches": "Exact matches only", "menu.collab": "Collab", + "menu.collab.aliases": "Collaboration;Multiplayer", "menu.collab.connect": "Connect to Server", "menu.collab.connect.error": "Error connecting to server", "menu.collab.disconnect": "Disconnect", @@ -108,6 +119,11 @@ "menu.help.about.version": "Version: %s", "menu.help.about.version.external": "%s Version: %s", "menu.help.github": "Github Page", + "menu.help.search": "Search menus", + "menu.help.search.placeholder": "Search menus...", + "menu.help.search.no_results": "No results", + "menu.help.search.hint.view": "Hold shift to view the selected result", + "menu.help.search.hint.choose": "Press enter to choose the selected result", "popup_menu.rename": "Rename", "popup_menu.paste": "Paste text", @@ -262,6 +278,7 @@ "prompt.open": "Open", "prompt.error": "Error", "prompt.invalid_input": "Invalid input", + "prompt.dismiss": "Dismiss", "prompt.search.classes": "Classes", "prompt.search.methods": "Methods", @@ -395,6 +412,8 @@ "notification.level.no_chat": "No chat messages", "notification.level.full": "All server notifications", + "dev.menu": "Dev", + "dev.menu.aliases": "Development;Debugging", "dev.menu.show_mapping_source_plugin": "Show mapping source plugin", "dev.menu.debug_token_highlights": "Debug token highlights", "dev.menu.log_client_packets": "Log client packets", diff --git a/enigma/src/test/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrieTest.java b/enigma/src/test/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrieTest.java new file mode 100644 index 000000000..c6d348473 --- /dev/null +++ b/enigma/src/test/java/org/quiltmc/enigma/util/multi_trie/CompositeStringMultiTrieTest.java @@ -0,0 +1,468 @@ +package org.quiltmc.enigma.util.multi_trie; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMultimap; +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; +import org.quiltmc.enigma.util.multi_trie.MutableStringMultiTrie.Node; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.sameInstance; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class CompositeStringMultiTrieTest { + private static final String VALUES = "values"; + private static final String LEAVES = "leaves"; + private static final String BRANCHES = "branches"; + + private static final String KEY_BY_KEY_SUBJECT = "key-by-key subject"; + + private static final String IGNORE_CASE_SUBJECT = "aBrAcAdAnIeL"; + + @SuppressWarnings("SameParameterValue") + private static String caseInverted(String string) { + final StringBuilder builder = new StringBuilder(); + for (int i = 0; i < string.length(); i++) { + final char c = string.charAt(i); + + final char inverted; + if (Character.isLowerCase(c)) { + inverted = Character.toUpperCase(c); + } else if (Character.isUpperCase(c)) { + inverted = Character.toLowerCase(c); + } else { + inverted = c; + } + + builder.append(inverted); + } + + return builder.toString(); + } + + // test key-by-key put's orphan logic + @Test + void testPutKeyByKeyFromRoot() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + for (int depth = 0; depth < KEY_BY_KEY_SUBJECT.length(); depth++) { + Node node = trie.getRoot(); + for (int iKey = 0; iKey <= depth; iKey++) { + node = node.next(KEY_BY_KEY_SUBJECT.charAt(iKey)); + } + + node.put(depth); + + assertOneLeaf(node); + + assertTrieSize(trie, depth + 1); + } + } + + // tests that key-by-key put's orphan logic propagates from stems to the root + @Test + void testPutKeyByKeyFromStems() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + for (int depth = KEY_BY_KEY_SUBJECT.length() - 1; depth >= 0; depth--) { + Node node = trie.getRoot(); + for (int iKey = 0; iKey <= depth; iKey++) { + node = node.next(KEY_BY_KEY_SUBJECT.charAt(iKey)); + } + + node.put(depth); + + assertOneLeaf(node); + + assertTrieSize(trie, KEY_BY_KEY_SUBJECT.length() - depth); + } + } + + @Test + void testDepth() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + for (int depth = 0; depth < KEY_BY_KEY_SUBJECT.length(); depth++) { + final Node root = trie.getRoot(); + assertThat("Root node depth", root.getDepth(), is(0)); + + Node node = root; + for (int iKey = 0; iKey <= depth; iKey++) { + node = node.next(KEY_BY_KEY_SUBJECT.charAt(iKey)); + } + + assertThat("Branch node depth", node.getDepth(), is(depth + 1)); + } + } + + @Test + void testPrevious() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + final Node root = trie.getRoot(); + assertSame(root, root.previous(), "Expected root.previous() to return itself!"); + + final List> nodes = new ArrayList<>(); + nodes.add(root); + + Node node = root; + for (int iKey = 0; iKey < KEY_BY_KEY_SUBJECT.length(); iKey++) { + node = node.next(KEY_BY_KEY_SUBJECT.charAt(iKey)); + nodes.add(node); + } + + for (int i = nodes.size() - 1; i > 0; i--) { + final Node subjectNode = nodes.get(i); + final Node expectedPrev = nodes.get(i - 1); + + assertThat(expectedPrev, sameInstance(subjectNode.previous())); + + for (int steps = 0; steps < subjectNode.getDepth(); steps++) { + final Node expectedStepPrev = nodes.get(i - steps); + + assertThat(expectedStepPrev, sameInstance(subjectNode.previous(steps))); + } + } + } + + private static void assertOneLeaf(StringMultiTrie.Node node) { + assertEquals( + 1, node.streamLeaves().count(), + () -> "Expected node to have only one leaf, but had the following: " + node.streamLeaves().toList() + ); + } + + private static void assertTrieSize(CompositeStringMultiTrie trie, int expectedSize) { + assertEquals( + expectedSize, trie.getSize(), + () -> "Expected node to have %s values, but had the following: %s" + .formatted(expectedSize, trie.getRoot().streamValues().toList()) + ); + } + + @Test + void testPut() { + final CompositeStringMultiTrie trie = Association.createAndPopulateTrie(); + + Association.BY_PREFIX.asMap().forEach((prefix, associations) -> { + final MultiTrie.Node node = trie.get(prefix); + + assertUnorderedContentsForPrefix(prefix, VALUES, associations.stream(), node.streamValues()); + + assertUnorderedContentsForPrefix( + prefix, LEAVES, + associations.stream().filter(association -> association.isLeafOf(prefix)), + node.streamLeaves() + ); + + assertUnorderedContentsForPrefix( + prefix, BRANCHES, + associations.stream().filter(association -> association.isBranchOf(prefix)), + node.streamStems() + ); + }); + } + + @Test + void testPutMulti() { + final CompositeStringMultiTrie trie = MultiAssociation.createAndPopulateTrie(); + + Association.BY_PREFIX.asMap().forEach((prefix, associations) -> { + final MultiTrie.Node node = trie.get(prefix); + + assertUnorderedContentsForPrefix( + prefix, VALUES, + MultiAssociation.streamWith(associations.stream()), + node.streamValues() + ); + + assertUnorderedContentsForPrefix( + prefix, LEAVES, + MultiAssociation.streamWith(associations.stream().filter(association -> association.isLeafOf(prefix))), + node.streamLeaves() + ); + + assertUnorderedContentsForPrefix( + prefix, BRANCHES, + MultiAssociation.streamWith(associations.stream().filter(a -> a.isBranchOf(prefix))), + node.streamStems() + ); + }); + } + + @Test + void testRemove() { + final CompositeStringMultiTrie trie = Association.createAndPopulateTrie(); + + Association.BY_PREFIX.asMap().forEach((prefix, associations) -> { + for (final Association association : associations) { + assertRemovalResult(trie, association.isLeafOf(prefix), prefix, association); + } + }); + + assertEmpty(trie); + } + + @Test + void testRemoveMulti() { + final CompositeStringMultiTrie trie = MultiAssociation.createAndPopulateTrie(); + + Association.BY_PREFIX.asMap().forEach((prefix, associations) -> { + for (final Association association : associations) { + final boolean expectRemoval = association.isLeafOf(prefix); + for (final MultiAssociation multiAssociation : MultiAssociation.BY_ASSOCIATION.get(association)) { + assertRemovalResult(trie, expectRemoval, prefix, multiAssociation); + } + } + }); + + assertEmpty(trie); + } + + @Test + void testRemoveAll() { + final CompositeStringMultiTrie trie = MultiAssociation.createAndPopulateTrie(); + + Association.BY_PREFIX.asMap().forEach((prefix, associations) -> { + final List leaves = associations.stream() + .filter(association -> association.isLeafOf(prefix)) + .toList(); + + final boolean expectRemoval = !leaves.isEmpty(); + assertEquals(expectRemoval, trie.removeAll(prefix), () -> { + return expectRemoval + ? "Expected removal of leaves with prefix \"%s\": %s" + .formatted(prefix, MultiAssociation.streamWith(leaves.stream()).toList()) + : "Expected no removal of nodes with prefix \"%s\": %s" + .formatted(prefix, MultiAssociation.streamWith(associations.stream()).toList()); + }); + }); + + assertEmpty(trie); + } + + private static void assertRemovalResult( + CompositeStringMultiTrie trie, boolean expectRemoval, String prefix, T value + ) { + assertEquals( + expectRemoval, + trie.remove(prefix, value), + () -> "Expected%s removal of \"%s\" with prefix \"%s\"!" + .formatted(expectRemoval ? "" : " no", value, prefix) + ); + } + + private static void assertEmpty(CompositeStringMultiTrie trie) { + assertTrue( + trie.isEmpty(), + () -> "Expected trie to be empty, but had it contents: " + trie.getRoot().streamValues().toList() + ); + + final Map> rootChildren = + ((CompositeStringMultiTrie.Root) trie.getRoot()).getBranches(); + assertTrue( + rootChildren.isEmpty(), + () -> "Expected root's children to be pruned, but it had children: " + rootChildren + ); + } + + private static void assertUnorderedContentsForPrefix( + String prefix, String arrayName, Stream expected, Stream actual + ) { + assertThat( + "Unexpected %s for prefix \"%s\"!".formatted(arrayName, prefix), + actual.toList(), + containsInAnyOrder(expected.toArray()) + ); + } + + @Test + void testStreamNextIgnoreCase() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + trie.put(IGNORE_CASE_SUBJECT, IGNORE_CASE_SUBJECT); + + final String invertedSubject = caseInverted(IGNORE_CASE_SUBJECT); + List> nodes = List.of(trie.getRoot()); + for (int i = 0; i < invertedSubject.length(); i++) { + final char key = invertedSubject.charAt(i); + nodes = nodes.stream().flatMap(node -> node.streamNextIgnoreCase(key)).toList(); + assertNodeCount(nodes, 1); + + assertOneValue(nodes.get(0)); + } + + assertOneLeaf(nodes.get(0)); + } + + private static void assertOneValue(StringMultiTrie.Node node) { + assertEquals( + 1, node.getSize(), + "Expected node to have only one value, but had the following: " + node.streamValues().toList() + ); + } + + @Test + void testGetIgnoreCase() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + trie.put(IGNORE_CASE_SUBJECT, IGNORE_CASE_SUBJECT); + + final String invertedSubject = caseInverted(IGNORE_CASE_SUBJECT); + + final List> singleNodes = trie.streamIgnoreCase(invertedSubject).toList(); + assertNodeCount(singleNodes, 1); + + final StringMultiTrie.Node singleNode = singleNodes.get(0); + assertOneValue(singleNode); + + singleNode.streamLeaves() + .findAny() + .orElseThrow(() -> new AssertionFailedError("Expected node to have a leaf, but had none!")); + + trie.put(invertedSubject, invertedSubject); + + final List> nodes = trie.streamIgnoreCase(IGNORE_CASE_SUBJECT).toList(); + assertNodeCount(nodes, 2); + + final List> invertedNodes = trie.streamIgnoreCase(invertedSubject).toList(); + + assertThat( + "Searching by non/inverted case keys should yield the same results!", + nodes, containsInAnyOrder(invertedNodes.toArray()) + ); + } + + @Test + void testViews() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + assertFalse(trie.view() instanceof MutableMultiTrie, "Trie view must not be mutable!"); + + assertFalse(trie.getRoot().view() instanceof MutableMultiTrie.Node, "Trie root view must not be mutable!"); + + assertThat( + "Root view should be the same as view root", + trie.view().getRoot(), sameInstance(trie.getRoot().view()) + ); + + final char key = '1'; + + // orphan node + final Node node = trie.getRoot().next(key); + + assertFalse(node.view() instanceof MutableMultiTrie.Node, "Trie branch view must not be mutable!"); + + assertThat( + "View lookups should be the same as trie lookup views", + node.view(), sameInstance(trie.view().getRoot().next(key)) + ); + } + + private static void assertNodeCount(Collection> nodes, int expected) { + assertThat("Node count", nodes.size(), is(expected)); + } + + record Association(String key) { + static final ImmutableList ALL = ImmutableList.of( + new Association(""), + new Association("A"), new Association("AB"), new Association("ABC"), + new Association("BA"), new Association("CBA"), + new Association("I"), new Association("II"), new Association("III"), + new Association("ONE"), new Association("TWO"), new Association("THREE"), + new Association("ENO"), new Association("OWT"), new Association("EERHT") + ); + + static final ImmutableMultimap BY_PREFIX; + + static { + final ImmutableMultimap.Builder byPrefix = ImmutableMultimap.builder(); + + ALL.forEach(association -> { + for (int i = 0; i <= association.key.length(); i++) { + byPrefix.put(association.key.substring(0, i), association); + } + }); + + BY_PREFIX = byPrefix.build(); + } + + static CompositeStringMultiTrie createAndPopulateTrie() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + for (final Association association : ALL) { + trie.put(association.key, association); + } + + return trie; + } + + boolean isLeafOf(String prefix) { + return this.key.equals(prefix); + } + + boolean isBranchOf(String prefix) { + return this.key.length() > prefix.length() && this.key.startsWith(prefix); + } + } + + record MultiAssociation(Association association, int id) { + static final int MAX_COUNT = 3; + + static final ImmutableList ALL; + static final ImmutableMultimap BY_ASSOCIATION; + + static { + final ImmutableList.Builder all = ImmutableList.builder(); + + final ImmutableMultimap.Builder byAssociation = ImmutableMultimap.builder(); + + int id = 0; + int count = 1; + for (final Association association : Association.ALL) { + int currentCount = count; + while (currentCount > 0) { + final MultiAssociation multiAssociation = new MultiAssociation(association, id++); + + all.add(multiAssociation); + byAssociation.put(association, multiAssociation); + + currentCount--; + } + + // prevent needless exponential growth + count = (count % MAX_COUNT) + 1; + } + + ALL = all.build(); + BY_ASSOCIATION = byAssociation.build(); + } + + static CompositeStringMultiTrie createAndPopulateTrie() { + final CompositeStringMultiTrie trie = CompositeStringMultiTrie.createHashed(); + + for (final MultiAssociation multiAssociation : ALL) { + trie.put(multiAssociation.association.key, multiAssociation); + } + + return trie; + } + + static Stream streamWith(Stream associations) { + return associations + .map(MultiAssociation.BY_ASSOCIATION::get) + .flatMap(Collection::stream); + } + } +}