Add shader setting search functionality and center selection (#1897)

This commit is contained in:
SpacEagle17 authored and GitHub committed 2026-06-29 19:36:55 -07:00
1 parent 5b704d1bc2
commit f07d96316a
15 files changed
+648 -61

No files matched your search

@@ -11,6 +11,7 @@ import net.coderbot.iris.gui.screen.ShaderPackScreen;
import net.coderbot.iris.shaderpack.ShaderPack;
import net.coderbot.iris.shaderpack.option.menu.OptionMenuContainer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiTextField;
import net.minecraft.client.renderer.Tessellator;
import java.util.ArrayList;
@@ -24,6 +25,12 @@ public class ShaderPackOptionList extends IrisGuiSlot {
private OptionMenuContainer container;
private final List<BaseEntry> entries = new ArrayList<>();
@Getter
private boolean searchModeActive = false;
private String typedSearchQuery = "";
private int reservedLeftWidth = 48; // Pixels reserved on the left of the header row for the search/clear button + gap.
private GuiTextField searchBox = null;
public ShaderPackOptionList(ShaderPackScreen screen, NavigationController navigation, ShaderPack pack, Minecraft client, int width, int height, int top, int bottom, int left, int right) {
super(client, width, height, top, bottom, 24);
this.navigation = navigation;
@@ -36,6 +43,74 @@ public class ShaderPackOptionList extends IrisGuiSlot {
this.container = pack.getMenuContainer();
}
/** Applies {@code query} to the option container and rebuilds the list rows. */
public void updateSearchQuery(String query) {
typedSearchQuery = query != null ? query : "";
if (this.container != null) {
this.container.setSearchQuery(query);
}
this.rebuild();
}
/** Resets all search state and clears the active filter without rebuilding entries. */
public void disableSearchMode() {
searchModeActive = false;
typedSearchQuery = "";
searchBox = null;
if (this.container != null) {
this.container.setSearchQuery(null);
}
}
public void disableSearchModeAndRebuild() {
disableSearchMode();
this.rebuild();
}
public void enableSearchModeAndRebuild() {
searchModeActive = true;
this.rebuild(); // First rebuild so that the button width is the current one
int listWidth = getListWidth();
int entryX = this.width / 2 - listWidth / 2;
int boxX = entryX + reservedLeftWidth + 3;
int boxY = this.top + 5; // got figured out by trial and error and works, don't question
int boxW = Math.max(40, listWidth - reservedLeftWidth - 5);
this.searchBox = new GuiTextField(Minecraft.getMinecraft().fontRenderer, boxX, boxY, boxW, 14);
this.searchBox.setMaxStringLength(64);
this.searchBox.setFocused(true);
}
/** True when the navigation stack is non-empty (a sub-screen is open). */
public boolean isOnSubScreen() {
return navigation != null && navigation.getCurrentScreen() != null;
}
public void setReservedLeftWidth(int w) {
reservedLeftWidth = Math.max(0, w);
}
/**
* Routes a keystroke into the live search text field, then propagates any text change
* to the option container and rebuilds the filtered list.
*/
public void handleSearchKeyTyped(char typedChar, int keyCode) {
if (searchBox == null || !searchModeActive) return;
searchBox.textboxKeyTyped(typedChar, keyCode);
String newText = searchBox.getText();
if (!newText.equals(typedSearchQuery)) {
updateSearchQuery(newText);
}
}
/** Draws the search text box overlay above the header row */
public void drawSearchBox() {
if (searchBox == null || !searchModeActive) return;
// Hide when the header has scrolled out of view
if (this.amountScrolled > 0.5f) return;
searchBox.drawTextBox();
}
public void rebuild() {
this.entries.clear();
this.amountScrolled = 0;
@@ -56,7 +131,7 @@ public class ShaderPackOptionList extends IrisGuiSlot {
}
public void addHeader(String text, boolean backButton) {
this.addEntry(new HeaderEntry(this.screen, this.navigation, text, backButton));
this.addEntry(new HeaderEntry(this.screen, this.navigation, text, backButton, this));
}
public void addWidgets(int columns, List<AbstractElementWidget<?>> elements) {
@@ -86,6 +161,15 @@ public class ShaderPackOptionList extends IrisGuiSlot {
return entries.size();
}
@Override
public boolean mouseClicked(int mouseX, int mouseY, int mouseButton) {
// Pass the click to the search box first so it can update its focus state
if (searchModeActive && searchBox != null && amountScrolled <= 0.5f) {
searchBox.mouseClicked(mouseX, mouseY, mouseButton);
}
return super.mouseClicked(mouseX, mouseY, mouseButton);
}
@Override
protected boolean elementClicked(int index, boolean doubleClick, int mouseX, int mouseY, int mouseButton) {
final BaseEntry entry = this.entries.get(index);
@@ -14,6 +14,7 @@ import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.Tessellator;
import net.minecraft.client.resources.I18n;
import net.minecraft.util.EnumChatFormatting;
import net.minecraft.util.MathHelper;
import java.io.IOException;
import java.nio.file.FileSystems;
@@ -142,10 +143,15 @@ public class ShaderPackSelectionList extends IrisGuiSlot {
topButtonRow.allowEnableShadersButton = !names.isEmpty();
int index = 0;
String selectedName = Iris.getIrisConfig().getShaderPackName().orElse(null);
int targetSelectionIndex = -1;
for (String name : names) {
this.addPackEntry(index, name);
if (name.equals(selectedName)) {
targetSelectionIndex = index;
}
index++;
addPackEntry(index, name);
}
// Try not to gaslight users in case they swap back and forth from lwjgl2 and lwjgl3
@@ -153,6 +159,29 @@ public class ShaderPackSelectionList extends IrisGuiSlot {
? "pack.iris.list.label"
: "pack.iris.list.label.lwjgl2";
addLabelEntries(EnumChatFormatting.GRAY.toString() + EnumChatFormatting.ITALIC + I18n.format(footerKey));
if (targetSelectionIndex != -1) {
scrollToIndex(targetSelectionIndex);
}
}
/**
* Scrolls the list to center the entry at {@code targetIndex} in the viewport.
*/
public void scrollToIndex(int targetIndex) {
if (targetIndex < 0 || targetIndex >= this.getSize()) {
return;
}
this.elementClicked(targetIndex, false, 0, 0);
this.selectedElement = targetIndex;
int viewportCenterY = (this.bottom - this.top) / 2;
int itemCenterPos = (targetIndex * this.slotHeight) + this.headerPadding + (this.slotHeight / 2);
float targetScroll = (float) (itemCenterPos - viewportCenterY);
float maxScroll = (float) this.func_148135_f();
this.amountScrolled = MathHelper.clamp_float(targetScroll, 0.0F, maxScroll);
}
public void addPackEntry(int index, String name) {
@@ -1,9 +1,11 @@
package net.coderbot.iris.gui.element.shaderoptions;
import com.gtnewhorizons.angelica.glsm.GLStateManager;
import net.coderbot.iris.Iris;
import net.coderbot.iris.gui.GuiUtil;
import net.coderbot.iris.gui.NavigationController;
import net.coderbot.iris.gui.element.IrisElementRow;
import net.coderbot.iris.gui.element.ShaderPackOptionList;
import net.coderbot.iris.gui.screen.ShaderPackScreen;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.FontRenderer;
@@ -16,58 +18,83 @@ import org.jetbrains.annotations.Nullable;
public class HeaderEntry extends BaseEntry {
public static final String BACK_BUTTON_TEXT = EnumChatFormatting.ITALIC + "< " + I18n.format("options.iris.back");
public static final String RESET_BUTTON_TEXT_INACTIVE = EnumChatFormatting.GRAY + I18n.format("options.iris.reset");
public static final String RESET_BUTTON_TEXT_ACTIVE = EnumChatFormatting.YELLOW + I18n.format("options.iris.reset");
public static final String RESET_HOLD_SHIFT_TOOLTIP = EnumChatFormatting.BOLD + I18n.format("options.iris.reset.tooltip.holdShift");
public static final String RESET_TOOLTIP = EnumChatFormatting.RED + I18n.format("options.iris.reset.tooltip");
public static final String IMPORT_TOOLTIP = I18n.format("options.iris.importSettings.tooltip");
// .withStyle(style -> style.withColor(TextColor.fromRgb(0x4da6ff)));
public static final String EXPORT_TOOLTIP = I18n.format("options.iris.exportSettings.tooltip");
// .withStyle(style -> style.withColor(TextColor.fromRgb(0xfc7d3d)));
private static String backButtonText() { return EnumChatFormatting.ITALIC + "< " + I18n.format("options.iris.back"); }
private static String resetButtonInactive() { return EnumChatFormatting.GRAY + I18n.format("options.iris.reset"); }
private static String resetButtonActive() { return EnumChatFormatting.YELLOW + I18n.format("options.iris.reset"); }
private static String resetHoldShiftTooltip() { return EnumChatFormatting.BOLD + I18n.format("options.iris.reset.tooltip.holdShift"); }
private static String resetTooltip() { return EnumChatFormatting.RED + I18n.format("options.iris.reset.tooltip"); }
private static String importTooltip() { return I18n.format("options.iris.importSettings.tooltip"); }
private static String exportTooltip() { return I18n.format("options.iris.exportSettings.tooltip"); }
private static String searchButtonText() { return I18n.format("options.iris.search.button"); }
private static String clearButtonText() { return I18n.format("options.iris.clear.button"); }
private static String searchTooltip() { return I18n.format("options.iris.search.tooltip"); }
private static String clearTooltip() { return I18n.format("options.iris.clear.tooltip"); }
private static final int MIN_SIDE_BUTTON_WIDTH = 42;
private static final int BUTTON_HEIGHT = 16;
private static final int SEARCH_BOX_GAP = 3;
private final ShaderPackScreen screen;
private final ShaderPackOptionList optionList;
private final @Nullable IrisElementRow backButton;
/** Width of the left-side button, used to clip the title text away from it. */
private final int leftButtonWidth;
private final IrisElementRow utilityButtons = new IrisElementRow();
private final IrisElementRow.TextButtonElement resetButton;
private final IrisElementRow.IconButtonElement importButton;
private final IrisElementRow.IconButtonElement exportButton;
private final String text;
public HeaderEntry(ShaderPackScreen screen, NavigationController navigation, String text, boolean hasBackButton) {
/** Non-null only on the main-screen header (search/clear toggle). */
private final @Nullable IrisElementRow.TextButtonElement searchToggleButton;
public HeaderEntry(ShaderPackScreen screen, NavigationController navigation, String text, boolean hasBackButton, ShaderPackOptionList optionList) {
super(navigation);
this.optionList = optionList;
this.screen = screen;
this.text = text;
IrisElementRow.TextButtonElement toggleRef = null;
if (hasBackButton) {
this.backButton = new IrisElementRow().add(new IrisElementRow.TextButtonElement(BACK_BUTTON_TEXT, this::backButtonClicked),
Math.max(MIN_SIDE_BUTTON_WIDTH, Minecraft.getMinecraft().fontRenderer.getStringWidth(BACK_BUTTON_TEXT) + 8));
// Sub-screen: navigating here while search is active → force-disable so the search box disappears
if (optionList.isSearchModeActive()) {
optionList.disableSearchMode();
}
String backText = backButtonText();
int w = Math.max(MIN_SIDE_BUTTON_WIDTH, Minecraft.getMinecraft().fontRenderer.getStringWidth(backText) + 8);
this.backButton = new IrisElementRow().add(new IrisElementRow.TextButtonElement(backText, this::backButtonClicked), w);
this.leftButtonWidth = w;
} else {
this.backButton = null;
// Main screen: left button is the search/clear toggle
String buttonText = optionList.isSearchModeActive() ? clearButtonText() : searchButtonText();
IrisElementRow.TextButtonElement searchBtn = new IrisElementRow.TextButtonElement(buttonText, this::searchButtonClicked);
int w = Math.max(MIN_SIDE_BUTTON_WIDTH, Minecraft.getMinecraft().fontRenderer.getStringWidth(buttonText) + 8);
this.backButton = new IrisElementRow().add(searchBtn, w);
toggleRef = searchBtn;
this.leftButtonWidth = w;
optionList.setReservedLeftWidth(w + SEARCH_BOX_GAP);
}
this.resetButton = new IrisElementRow.TextButtonElement(RESET_BUTTON_TEXT_INACTIVE, this::resetButtonClicked);
this.searchToggleButton = toggleRef;
this.resetButton = new IrisElementRow.TextButtonElement(resetButtonInactive(), this::resetButtonClicked);
this.importButton = new IrisElementRow.IconButtonElement(GuiUtil.Icon.IMPORT, GuiUtil.Icon.IMPORT_COLORED, this::importSettingsButtonClicked);
this.exportButton = new IrisElementRow.IconButtonElement(GuiUtil.Icon.EXPORT, GuiUtil.Icon.EXPORT_COLORED, this::exportSettingsButtonClicked);
this.utilityButtons.add(this.importButton, 15).add(this.exportButton, 15)
.add(this.resetButton, Math.max(MIN_SIDE_BUTTON_WIDTH, Minecraft.getMinecraft().fontRenderer.getStringWidth((RESET_BUTTON_TEXT_INACTIVE) + 8)));
this.screen = screen;
this.text = text;
.add(this.resetButton, Math.max(MIN_SIDE_BUTTON_WIDTH, Minecraft.getMinecraft().fontRenderer.getStringWidth(resetButtonInactive()) + 8));
}
@Override
public void drawEntry(ShaderPackScreen screen, int index, int x, int y, int slotWidth, int slotHeight, Tessellator tessellator, int mouseX, int mouseY, boolean isMouseOver) {
Gui.drawRect(x - 3, (y + slotHeight) - 3, x + slotWidth, (y + slotHeight) - 2, 0x66BEBEBE);
// Gui.drawRect leaves GL color set to the rect's color; reset to white so button textures aren't tinted.
GLStateManager.glColor4f(1.0f, 1.0f, 1.0f, 1.0f);
final int tickDelta = 0;
final FontRenderer font = Minecraft.getMinecraft().fontRenderer;
GuiUtil.drawScrollingText(font, text, x + (int) (slotWidth * 0.5), x + 5,
((x + slotWidth) - 10) - this.utilityButtons.getWidth(), y + 5, y + 15, 0xFFFFFF);
GuiUtil.bindIrisWidgetsTexture();
// Draw back button if present
@@ -75,26 +102,42 @@ public class HeaderEntry extends BaseEntry {
backButton.drawScreen(x, y, BUTTON_HEIGHT, mouseX, mouseY, tickDelta, isMouseOver);
}
if (optionList.isSearchModeActive() && searchToggleButton != null) {
if (searchToggleButton.isHovered()) {
ShaderPackScreen.TOP_LAYER_RENDER_QUEUE.add(() -> GuiUtil.drawTextPanel(font, clearTooltip(), mouseX, mouseY - 16));
}
return;
}
GuiUtil.drawScrollingText(font, text, x + (int) (slotWidth * 0.5),
x + leftButtonWidth + 5,
((x + slotWidth) - 10) - this.utilityButtons.getWidth(),
y + 5, y + 15, 0xFFFFFF);
final boolean shiftDown = GuiScreen.isShiftKeyDown();
// Set the appearance of the reset button
this.resetButton.disabled = !shiftDown;
this.resetButton.text = shiftDown ? RESET_BUTTON_TEXT_ACTIVE : RESET_BUTTON_TEXT_INACTIVE;
this.resetButton.text = shiftDown ? resetButtonActive() : resetButtonInactive();
// Draw the utility buttons
this.utilityButtons.renderRightAligned((x + slotWidth) - 3, y, BUTTON_HEIGHT, mouseX, mouseY, tickDelta, isMouseOver);
// Draw the reset button's tooltip
if (this.resetButton.isHovered()) {
final String tooltip = shiftDown ? RESET_TOOLTIP : RESET_HOLD_SHIFT_TOOLTIP;
final String tooltip = shiftDown ? resetTooltip() : resetHoldShiftTooltip();
queueBottomRightAnchoredTooltip(mouseX, mouseY, font, tooltip);
}
// Draw the import/export button tooltips
if (this.importButton.isHovered()) {
queueBottomRightAnchoredTooltip(mouseX, mouseY, font, IMPORT_TOOLTIP);
queueBottomRightAnchoredTooltip(mouseX, mouseY, font, importTooltip());
}
if (this.exportButton.isHovered()) {
queueBottomRightAnchoredTooltip(mouseX, mouseY, font, EXPORT_TOOLTIP);
queueBottomRightAnchoredTooltip(mouseX, mouseY, font, exportTooltip());
}
// Search-button tooltip
if (searchToggleButton != null && searchToggleButton.isHovered()) {
ShaderPackScreen.TOP_LAYER_RENDER_QUEUE.add(() -> GuiUtil.drawTextPanel(font, searchTooltip(), mouseX, mouseY - 16));
}
}
@@ -104,6 +147,9 @@ public class HeaderEntry extends BaseEntry {
@Override
public boolean mouseClicked(int mouseX, int mouseY, int button) {
if (optionList.isSearchModeActive()) {
return backButton != null && backButton.mouseClicked(mouseX, mouseY, button);
}
final boolean backButtonResult = backButton != null && backButton.mouseClicked(mouseX, mouseY, button);
final boolean utilButtonResult = utilityButtons.mouseClicked(mouseX, mouseY, button);
@@ -122,6 +168,16 @@ public class HeaderEntry extends BaseEntry {
return true;
}
private boolean searchButtonClicked(IrisElementRow.TextButtonElement button) {
GuiUtil.playButtonClickSound();
if (optionList.isSearchModeActive()) {
optionList.disableSearchModeAndRebuild();
} else {
optionList.enableSearchModeAndRebuild();
}
return true;
}
private boolean resetButtonClicked(IrisElementRow.TextButtonElement button) {
if (GuiScreen.isShiftKeyDown()) {
Iris.resetShaderPackOptionsOnNextReload();
@@ -10,9 +10,10 @@ import net.minecraft.client.resources.I18n;
import java.util.function.Function;
public class TopButtonRowEntry extends BaseEntry {
private static final String NONE_PRESENT_LABEL = I18n.format("options.iris.shaders.nonePresent"); //.withStyle(ChatFormatting.GRAY);
private static final String SHADERS_DISABLED_LABEL = I18n.format("options.iris.shaders.disabled");
private static final String SHADERS_ENABLED_LABEL = I18n.format("options.iris.shaders.enabled");
private static String nonePresentLabel() { return I18n.format("options.iris.shaders.nonePresent"); }
private static String shadersDisabledLabel() { return I18n.format("options.iris.shaders.disabled"); }
private static String shadersEnabledLabel() { return I18n.format("options.iris.shaders.enabled"); }
private final ShaderPackSelectionList shaderPackSelectionList;
private final IrisElementRow buttons = new IrisElementRow();
@@ -54,7 +55,7 @@ public class TopButtonRowEntry extends BaseEntry {
}
private String getEnableDisableLabel() {
return this.allowEnableShadersButton ? this.shadersEnabled ? SHADERS_ENABLED_LABEL : SHADERS_DISABLED_LABEL : NONE_PRESENT_LABEL;
return this.allowEnableShadersButton ? this.shadersEnabled ? shadersEnabledLabel() : shadersDisabledLabel() : nonePresentLabel();
}
public boolean mouseClicked(double mouseX, double mouseY, int button) {
@@ -4,16 +4,19 @@ import net.coderbot.iris.gui.GuiUtil;
import net.coderbot.iris.gui.NavigationController;
import net.coderbot.iris.gui.screen.ShaderPackScreen;
import net.coderbot.iris.shaderpack.option.menu.OptionMenuElement;
import net.coderbot.iris.shaderpack.option.menu.OptionMenuOptionElement;
import net.coderbot.iris.shaderpack.option.menu.ShaderSearchEngine;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.FontRenderer;
import net.minecraft.client.gui.GuiScreen;
import net.minecraft.client.resources.I18n;
import net.minecraft.util.EnumChatFormatting;
import org.jetbrains.annotations.Nullable;
import java.util.Optional;
public abstract class BaseOptionElementWidget<T extends OptionMenuElement> extends CommentedElementWidget<T> {
protected static final String SET_TO_DEFAULT = I18n.format("options.iris.setToDefault");
protected static String setToDefault() { return I18n.format("options.iris.setToDefault"); }
protected static final String DIVIDER = ": ";
protected String unmodifiedLabel;
@@ -101,7 +104,7 @@ public abstract class BaseOptionElementWidget<T extends OptionMenuElement> exten
protected final void tryRenderTooltip(int mouseX, int mouseY, boolean hovered) {
if (GuiScreen.isShiftKeyDown()) {
renderTooltip(SET_TO_DEFAULT, mouseX, mouseY, hovered);
renderTooltip(setToDefault(), mouseX, mouseY, hovered);
} else if (this.isLabelTrimmed && !this.screen.isDisplayingComment()) {
renderTooltip(this.unmodifiedLabel, mouseX, mouseY, hovered);
}
@@ -145,13 +148,39 @@ public abstract class BaseOptionElementWidget<T extends OptionMenuElement> exten
@Override
public Optional<String> getCommentBody() {
final String key = getCommentKey();
if (key == null) {
return Optional.empty();
Optional<String> base = Optional.ofNullable(getCommentKey()).map(I18n::format);
if (this.screen == null || !this.screen.isOptionMenuSearchActive()) {
return base;
}
final String translated = GuiUtil.translateLenient(key);
// Don't show comment boxes without a translation, otherwise you get something like option.{commentname}.comment
return translated.equals(key) ? Optional.empty() : Optional.of(translated);
String pathLabel = buildTranslatedPath();
if (pathLabel == null) {
return base;
}
String breadcrumb = EnumChatFormatting.BOLD.toString() + EnumChatFormatting.ITALIC + pathLabel + EnumChatFormatting.RESET;
if (base.isPresent()) {
return Optional.of(breadcrumb + ". " + base.get());
}
return Optional.of(breadcrumb);
}
@Nullable
private String buildTranslatedPath() {
if (!(this.element instanceof OptionMenuOptionElement)) return null;
OptionMenuOptionElement optEl = (OptionMenuOptionElement) this.element;
String rawPath = optEl.container.getOptionPath(optEl.optionId);
String[] segments = rawPath.split("/");
StringBuilder display = new StringBuilder();
for (String segment : segments) {
if ("root".equals(segment)) continue;
String translated = ShaderSearchEngine.getDisplaySettingsName(segment).replaceAll("\\s+>", "");
String label = translated.isEmpty() ? segment : translated;
if (display.length() > 0) display.append(" > ");
display.append(label);
}
return display.length() > 0 ? display.toString() : null;
}
@Override
@@ -9,10 +9,9 @@ import net.coderbot.iris.shaderpack.option.menu.OptionMenuBooleanOptionElement;
import net.minecraft.client.resources.I18n;
public class BooleanElementWidget extends BaseOptionElementWidget<OptionMenuBooleanOptionElement> {
private static final String TEXT_TRUE = I18n.format("label.iris.true");
private static final String TEXT_FALSE = I18n.format("label.iris.false");
private static final String TEXT_TRUE_DEFAULT = I18n.format("label.iris.true");
private static final String TEXT_FALSE_DEFAULT = I18n.format("label.iris.false");
private static String textTrue() { return I18n.format("label.iris.true"); }
private static String textFalse() { return I18n.format("label.iris.false"); }
private final BooleanOption option;
@@ -63,11 +62,7 @@ public class BooleanElementWidget extends BaseOptionElementWidget<OptionMenuBool
// Compatibility Mode to "On" when not needed. Now we use white for
// default to avoid giving a positive or negative connotation to a
// default value.
if (this.value == this.defaultValue) {
return this.value ? TEXT_TRUE_DEFAULT : TEXT_FALSE_DEFAULT;
}
return this.value ? TEXT_TRUE : TEXT_FALSE;
return this.value ? textTrue() : textFalse();
}
@Override
@@ -16,8 +16,9 @@ import net.minecraft.client.resources.I18n;
import java.util.Optional;
public class ProfileElementWidget extends BaseOptionElementWidget<OptionMenuProfileElement> {
private static final String PROFILE_LABEL = I18n.format("options.iris.profile");
private static final String PROFILE_CUSTOM = I18n.format("options.iris.profile.custom");
private static String profileLabel() { return I18n.format("options.iris.profile"); }
private static String profileCustom() { return I18n.format("options.iris.profile.custom"); }
private Profile next;
private Profile previous;
@@ -30,7 +31,7 @@ public class ProfileElementWidget extends BaseOptionElementWidget<OptionMenuProf
@Override
public void init(ShaderPackScreen screen, NavigationController navigation) {
super.init(screen, navigation);
this.setLabel(PROFILE_LABEL);
this.setLabel(profileLabel());
boolean secondProfileSet = ProfileElementTracker.isSecondProfileSet(this.element);
final ProfileSet profiles = this.element.profiles;
@@ -44,12 +45,12 @@ public class ProfileElementWidget extends BaseOptionElementWidget<OptionMenuProf
final Optional<String> profileName = result.current.map(p -> p.name);
String translationKey = "profile" + (secondProfileSet ? "2." : ".") + profileName.orElse("custom");
this.profileLabel = profileName.map(name -> GuiUtil.translateOrDefault(name, translationKey)).orElse(PROFILE_CUSTOM);
this.profileLabel = profileName.map(name -> GuiUtil.translateOrDefault(name, translationKey)).orElse(profileCustom());
}
@Override
public void drawScreen(int x, int y, int width, int height, int mouseX, int mouseY, float tickDelta, boolean hovered) {
this.updateRenderParams(width, width - (Minecraft.getMinecraft().fontRenderer.getStringWidth(PROFILE_LABEL) + 16));
this.updateRenderParams(width, width - (Minecraft.getMinecraft().fontRenderer.getStringWidth(profileLabel()) + 16));
this.renderOptionWithValue(x, y, width, height, hovered);
}
@@ -61,12 +62,12 @@ public class ProfileElementWidget extends BaseOptionElementWidget<OptionMenuProf
@Override
protected int getValueColor() {
return PROFILE_CUSTOM.equals(this.profileLabel) ? 0xFFFF55 : 0xFFFFFF;
return profileCustom().equals(this.profileLabel) ? 0xFFFF55 : 0xFFFFFF;
}
@Override
public Optional<String> getCommentTitle() {
return Optional.of(PROFILE_LABEL);
return Optional.of(profileLabel());
}
@Override
@@ -28,7 +28,7 @@ public class SliderElementWidget extends StringElementWidget {
}
if (GuiScreen.isShiftKeyDown()) {
renderTooltip(SET_TO_DEFAULT, mouseX, mouseY, hovered);
renderTooltip(setToDefault(), mouseX, mouseY, hovered);
} else if (!this.screen.isDisplayingComment()) {
renderTooltip(this.unmodifiedLabel, mouseX, mouseY, hovered);
}
@@ -53,8 +53,8 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
*/
public static final Set<Runnable> TOP_LAYER_RENDER_QUEUE = new HashSet<>();
private static final String SELECT_TITLE = EnumChatFormatting.GRAY.toString() + EnumChatFormatting.ITALIC + I18n.format("pack.iris.select.title");
private static final String CONFIGURE_TITLE = EnumChatFormatting.GRAY.toString() + EnumChatFormatting.ITALIC + I18n.format("pack.iris.configure.title");
private static String selectTitle() { return EnumChatFormatting.GRAY.toString() + EnumChatFormatting.ITALIC + I18n.format("pack.iris.select.title"); }
private static String configureTitle() { return EnumChatFormatting.GRAY.toString() + EnumChatFormatting.ITALIC + I18n.format("pack.iris.configure.title"); }
private static final int COMMENT_PANEL_WIDTH = 314;
private final GuiScreen parent;
@@ -104,6 +104,11 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
refreshForChangedPack();
}
/** Returns true when the option menu is open and the user has an active search query. */
public boolean isOptionMenuSearchActive() {
return this.optionMenuOpen && this.shaderOptionList != null && this.shaderOptionList.isSearchModeActive();
}
@Override
public void drawScreen(int mouseX, int mouseY, float delta) {
GLStateManager.glPushAttrib(GL11.GL_COLOR_BUFFER_BIT);
@@ -124,6 +129,7 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
if (!this.guiHidden) {
if (optionMenuOpen && this.shaderOptionList != null) {
this.shaderOptionList.drawScreen(mouseX, mouseY, delta);
this.shaderOptionList.drawSearchBox();
} else {
this.shaderPackList.drawScreen(mouseX, mouseY, delta);
}
@@ -144,9 +150,9 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
drawCenteredString(this.fontRendererObj, notificationDialog, (int) (this.width * 0.5), 21, 0xFFFFFF);
} else {
if (optionMenuOpen) {
drawCenteredString(this.fontRendererObj, CONFIGURE_TITLE, (int) (this.width * 0.5), 21, 0xFFFFFF);
drawCenteredString(this.fontRendererObj, configureTitle(), (int) (this.width * 0.5), 21, 0xFFFFFF);
} else {
drawCenteredString(this.fontRendererObj, SELECT_TITLE, (int) (this.width * 0.5), 21, 0xFFFFFF);
drawCenteredString(this.fontRendererObj, selectTitle(), (int) (this.width * 0.5), 21, 0xFFFFFF);
}
}
@@ -207,6 +213,10 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
if (Iris.getCurrentPack().isPresent() && this.navigation != null) {
final ShaderPack currentPack = Iris.getCurrentPack().get();
if (currentPack.getMenuContainer() != null) {
currentPack.getMenuContainer().setSearchQuery(null);
}
this.shaderOptionList = new ShaderPackOptionList(this, this.navigation, currentPack, this.mc, this.width, this.height, 32, this.height - 58, 0, this.width);
this.navigation.setActiveOptionList(this.shaderOptionList);
@@ -364,6 +374,31 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
@Override
protected void keyTyped(char typedChar, int keyCode) {
// Route all input to the search box while search mode is active
if (optionMenuOpen && this.shaderOptionList != null && this.shaderOptionList.isSearchModeActive()) {
if (keyCode == Keyboard.KEY_ESCAPE) {
this.shaderOptionList.disableSearchModeAndRebuild();
return;
}
// Ctrl+F while search is open: toggle it closed
if (keyCode == Keyboard.KEY_F && GuiScreen.isCtrlKeyDown()) {
GuiUtil.playButtonClickSound();
this.shaderOptionList.disableSearchModeAndRebuild();
return;
}
this.shaderOptionList.handleSearchKeyTyped(typedChar, keyCode);
return;
}
// Ctrl+F: open search on the main option screen
if (optionMenuOpen && this.shaderOptionList != null
&& keyCode == Keyboard.KEY_F && GuiScreen.isCtrlKeyDown()) {
if (!this.shaderOptionList.isOnSubScreen()) {
GuiUtil.playButtonClickSound();
this.shaderOptionList.enableSearchModeAndRebuild();
return;
}
}
if (GuiScreen.isCtrlKeyDown() && keyCode == Keyboard.KEY_D) {
this.mc.displayGuiScreen(new GuiYesNo((result, id) -> {
Iris.setDebug(result);
@@ -496,6 +531,10 @@ public class ShaderPackScreen extends GuiScreen implements HudHideable {
}
public void onClose() {
if (this.shaderOptionList != null) {
this.shaderOptionList.disableSearchModeAndRebuild();
}
if (!dropChanges) {
applyChanges();
} else {
@@ -10,9 +10,12 @@ import net.coderbot.iris.shaderpack.option.ShaderPackOptions;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
public class OptionMenuContainer {
public final OptionMenuElementScreen mainScreen;
@@ -24,6 +27,8 @@ public class OptionMenuContainer {
private final Map<List<OptionMenuElement>, Integer> unusedOptionDumpQueue = new HashMap<>(); // Used by screens with "*" element
@Getter private final ProfileSet profiles;
@Getter private final ProfileSet profiles2;
private final List<OptionMenuElement> originalMainElements = new ArrayList<>(); // Saved snapshot of mainScreen.elements before any search filter is applied.
private final Map<String, String> cachedOptionPaths = new HashMap<>(); // Full "root/SCREEN1/SCREEN2" path for every option ID, built once after construction
public OptionMenuContainer(ShaderProperties shaderProperties, ShaderPackOptions shaderPackOptions, ProfileSet profiles) {
this.profiles = profiles;
@@ -69,6 +74,10 @@ public class OptionMenuContainer {
entry.getKey().addAll(entry.getValue(), elementsToInsert);
}
// Save the original layout and build the path cache once the full tree is constructed
this.originalMainElements.addAll(this.mainScreen.elements);
generateAllPaths();
}
// Screens will call this when they contain a "*" element, so that the list of
@@ -85,4 +94,81 @@ public class OptionMenuContainer {
unusedOptions.remove(optionId);
}
private void generateAllPaths() {
cachedOptionPaths.clear();
traverseScreen(this.mainScreen, "root", new HashSet<String>());
}
private void traverseScreen(OptionMenuElementScreen screen, String currentPath, Set<String> visited) {
if (screen == null) return;
for (OptionMenuElement element : screen.elements) {
if (element instanceof OptionMenuOptionElement optEl) {
if (optEl.optionId != null) {
cachedOptionPaths.putIfAbsent(optEl.optionId, currentPath);
}
} else if (element instanceof OptionMenuLinkElement link) {
if (link.targetScreenId != null) {
String targetId = link.targetScreenId;
if (visited.add(targetId)) {
OptionMenuElementScreen next = this.subScreens.get(targetId);
if (next != null) {
traverseScreen(next, currentPath + "/" + targetId, visited);
}
visited.remove(targetId);
}
}
}
}
}
public String getOptionPath(String optionId) {
if (optionId == null) return "root";
return cachedOptionPaths.getOrDefault(optionId, "root");
}
/**
* Filters and re-orders the main-screen options to match {@code query}, or restores the
* original layout when {@code query} is null or blank.
*/
public void setSearchQuery(String query) {
if (query == null || query.trim().isEmpty()) {
restoreOriginalLayout();
return;
}
String normalizedQuery = query.toLowerCase(Locale.ROOT).trim();
List<OptionMenuOptionElement> flatOptions = ShaderSearchEngine.getAllOptionsFlattened(usedOptionElements);
List<ShaderSearchEngine.ScoredOptionElement> scored = new ArrayList<>();
for (OptionMenuOptionElement el : flatOptions) {
int score = ShaderSearchEngine.computeMatchTier(el.optionId, normalizedQuery);
if (score > 0) {
scored.add(new ShaderSearchEngine.ScoredOptionElement(
el,
ShaderSearchEngine.getReadableTranslatedName(el.optionId),
ShaderSearchEngine.getReadableDefaultName(el.optionId),
getOptionPath(el.optionId),
score,
normalizedQuery
));
}
}
Collections.sort(scored);
applyFilteredLayout(scored);
}
private void applyFilteredLayout(List<ShaderSearchEngine.ScoredOptionElement> sortedElements) {
this.mainScreen.elements.clear();
for (ShaderSearchEngine.ScoredOptionElement scored : sortedElements) {
this.mainScreen.elements.add(scored.getElement());
}
}
private void restoreOriginalLayout() {
this.mainScreen.elements.clear();
this.mainScreen.elements.addAll(this.originalMainElements);
}
}
@@ -0,0 +1,251 @@
package net.coderbot.iris.shaderpack.option.menu;
import lombok.Getter;
import net.coderbot.iris.Iris;
import net.coderbot.iris.shaderpack.ShaderPack;
import net.minecraft.client.resources.I18n;
import org.jspecify.annotations.NonNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
/**
* Utility class for searching and scoring shader option elements based on a query string.
* <br>By SpacEagle17
*/
public class ShaderSearchEngine {
private static final String WHOLE_WORD_REGEX = "(?<=^|[^a-zA-Z0-9])%s(?=$|[^a-zA-Z0-9])";
private static final String STARTS_WITH_REGEX = "(?<=^|[^a-zA-Z0-9])%s";
private static final Pattern COLOR_CODE_PATTERN = Pattern.compile("§.");
/**
* Bitmask of bits from readable-name (translated + default) comparisons.
* Used by the comparator to separate name-match quality from rawId/comment noise.
*/
private static final int READABLE_NAME_BITS =
(1 << 13) | (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 3) | (1 << 2);
private static String strippedLanguageString(String key) {
String translated = I18n.format(key);
if (translated.equals(key)) return "";
return COLOR_CODE_PATTERN.matcher(translated).replaceAll("");
}
/** Translated display name for a sub-screen ID (looks up {@code "screen.<screenId>"}). */
public static String getDisplaySettingsName(String screenId) {
return strippedLanguageString("screen." + screenId);
}
/** Lowercase Minecraft-locale translation of an option's human-readable name. */
public static String getReadableTranslatedName(String optionId) {
return strippedLanguageString("option." + optionId).toLowerCase(Locale.ROOT);
}
/**
* Lowercase default (en_us from the shader pack's lang folder) translation.
* Returns {@code ""} when the current pack has no en_us lang file or the key is missing.
*/
public static String getReadableDefaultName(String optionId) {
try {
Optional<ShaderPack> packOpt = Iris.getCurrentPack();
if (packOpt.isEmpty()) return "";
Map<String, String> translations = packOpt.get().getLanguageMap().getTranslations("en_us");
if (translations == null) return "";
String value = translations.get("option." + optionId);
if (value == null) return "";
return COLOR_CODE_PATTERN.matcher(value).replaceAll("").toLowerCase(Locale.ROOT);
} catch (Throwable t) {
return "";
}
}
private static boolean isOnlyAscii(String s) {
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) > 127) return false;
}
return true;
}
/**
* Computes a 14-bit bitmask score for how well {@code optionId} matches {@code query}.
* Returns 0 when there is no match at all.
* <br> Read code to know what each bit means lmao
*/
public static int computeMatchTier(String optionId, String query) {
try {
if (optionId == null || query == null) return 0;
String q = query.toLowerCase(Locale.ROOT).trim();
if (q.isEmpty()) return 0;
String translatedName = getReadableTranslatedName(optionId);
String defaultName = getReadableDefaultName(optionId);
// Single ASCII char: only match when a readable name begins with it
if (q.length() == 1 && isOnlyAscii(q)) {
return (!translatedName.isEmpty() && translatedName.startsWith(q)) ? 1 : 0;
}
String escaped = Pattern.quote(q);
Pattern wholeWord = Pattern.compile(String.format(WHOLE_WORD_REGEX, escaped));
Pattern startsWith = Pattern.compile(String.format(STARTS_WITH_REGEX, escaped));
String rawId = optionId.toLowerCase(Locale.ROOT);
String commentText = strippedLanguageString("option." + optionId + ".comment").toLowerCase(Locale.ROOT);
int score = 0;
if (!translatedName.isEmpty() && translatedName.equals(q)) score |= (1 << 13);
if (!defaultName.isEmpty() && defaultName.equals(q)) score |= (1 << 12);
if (!translatedName.isEmpty() && wholeWord.matcher(translatedName).find()) score |= (1 << 11);
if (!defaultName.isEmpty() && wholeWord.matcher(defaultName).find()) score |= (1 << 10);
if (!translatedName.isEmpty() && startsWith.matcher(translatedName).find()) score |= (1 << 9);
if (!defaultName.isEmpty() && startsWith.matcher(defaultName).find()) score |= (1 << 8);
if (wholeWord.matcher(rawId).find()) score |= (1 << 7);
if (!commentText.isEmpty() && wholeWord.matcher(commentText).find()) score |= (1 << 6);
if (startsWith.matcher(rawId).find()) score |= (1 << 5);
if (!commentText.isEmpty() && startsWith.matcher(commentText).find()) score |= (1 << 4);
if (!translatedName.isEmpty() && translatedName.contains(q)) score |= (1 << 3);
if (!defaultName.isEmpty() && defaultName.contains(q)) score |= (1 << 2);
if (rawId.contains(q)) score |= (1 << 1);
if (!commentText.isEmpty() && commentText.contains(q)) score |= 1;
return score;
} catch (Exception e) {
return 0;
}
}
/** De-duplicates and flattens the pool of registered option elements. */
public static List<OptionMenuOptionElement> getAllOptionsFlattened(List<OptionMenuOptionElement> usedOptionElements) {
List<OptionMenuOptionElement> flat = new ArrayList<>();
List<String> seen = new ArrayList<>();
for (OptionMenuOptionElement el : usedOptionElements) {
if (el == null) continue;
String id = el.optionId != null ? el.optionId : el.toString();
if (!seen.contains(id)) {
seen.add(id);
flat.add(el);
}
}
return flat;
}
/**
* A scored search result carrying the original element and all data required for sorting.
*/
public static final class ScoredOptionElement implements Comparable<ScoredOptionElement> {
@Getter
private final OptionMenuOptionElement element;
public final String readableTranslatedName;
public final String readableDefaultName;
public final String path;
public final int score;
public final String query;
public ScoredOptionElement(OptionMenuOptionElement element,
String readableTranslatedName,
String readableDefaultName,
String path,
int score,
String query) {
this.element = element;
this.readableTranslatedName = readableTranslatedName;
this.readableDefaultName = readableDefaultName;
this.path = path;
this.score = score;
this.query = query;
}
@Override
public int compareTo(@NonNull ScoredOptionElement other) {
String q = this.query != null ? this.query.toLowerCase(Locale.ROOT).trim() : "";
int r;
if ((r = compareByReadableNameQuality(this, other)) != 0) return r;
if ((r = compareByMatchedWordLength(this, other, q)) != 0) return r;
if ((r = compareByWordCount(this, other)) != 0) return r;
if ((r = compareByFullScore(this, other)) != 0) return r;
if ((r = compareByPrefixBoost(this, other, q)) != 0) return r;
if ((r = compareByPathDepth(this, other)) != 0) return r;
return compareByAlphabetical(this, other);
}
// 1. How well does the readable name (translated + default) match?
private static int compareByReadableNameQuality(ScoredOptionElement a, ScoredOptionElement b) {
return Integer.compare(b.score & READABLE_NAME_BITS, a.score & READABLE_NAME_BITS);
}
// 2. Shorter matched word = query covers a higher fraction of that word.
private static int compareByMatchedWordLength(ScoredOptionElement a, ScoredOptionElement b, String q) {
if (q.isEmpty()) return 0;
String aw = findMatchingWord(a.readableTranslatedName, q);
if (aw == null) aw = findMatchingWord(a.readableDefaultName, q);
String bw = findMatchingWord(b.readableTranslatedName, q);
if (bw == null) bw = findMatchingWord(b.readableDefaultName, q);
int aLen = aw != null ? aw.length() : Integer.MAX_VALUE;
int bLen = bw != null ? bw.length() : Integer.MAX_VALUE;
return Integer.compare(aLen, bLen);
}
// 3. Fewer words = more precise ("Bloom" before "Bloom Strength").
private static int compareByWordCount(ScoredOptionElement a, ScoredOptionElement b) {
String ae = !a.readableTranslatedName.isEmpty() ? a.readableTranslatedName : a.readableDefaultName;
String be = !b.readableTranslatedName.isEmpty() ? b.readableTranslatedName : b.readableDefaultName;
return Integer.compare(countWords(ae), countWords(be));
}
// 4. Higher raw score wins.
private static int compareByFullScore(ScoredOptionElement a, ScoredOptionElement b) {
return Integer.compare(b.score, a.score);
}
// 5. Prefix boost: readable name starts with the exact query string.
private static int compareByPrefixBoost(ScoredOptionElement a, ScoredOptionElement b, String q) {
if (q.isEmpty()) return 0;
boolean ap = (a.readableTranslatedName != null && a.readableTranslatedName.startsWith(q))
|| (a.readableDefaultName != null && a.readableDefaultName.startsWith(q));
boolean bp = (b.readableTranslatedName != null && b.readableTranslatedName.startsWith(q))
|| (b.readableDefaultName != null && b.readableDefaultName.startsWith(q));
if (ap == bp) return 0;
return ap ? -1 : 1;
}
// 6. Shallower option path wins (fewer slashes = closer to root menu).
private static int compareByPathDepth(ScoredOptionElement a, ScoredOptionElement b) {
return Integer.compare(countSlashes(a.path), countSlashes(b.path));
}
// 7. Alphabetical tie-breaker.
private static int compareByAlphabetical(ScoredOptionElement a, ScoredOptionElement b) {
String aId = a.element != null ? a.element.optionId : null;
String bId = b.element != null ? b.element.optionId : null;
if (aId != null && bId != null) return aId.compareTo(bId);
return 0;
}
private static String findMatchingWord(String name, String query) {
if (name == null || query.isEmpty()) return null;
for (String word : name.split("\\s+")) {
if (word.startsWith(query)) return word;
}
return null;
}
private static int countWords(String s) {
if (s == null || s.trim().isEmpty()) return 0;
return s.trim().split("\\s+").length;
}
private static int countSlashes(String path) {
if (path == null) return 0;
int count = 0;
for (int i = 0; i < path.length(); i++) {
if (path.charAt(i) == '/') count++;
}
return count;
}
}
}
@@ -145,3 +145,7 @@ pack.iris.select.title=Auswählen
pack.iris.configure.title=Konfigurieren
label.iris.true=An
label.iris.false=Aus
options.iris.search.button=Suchen
options.iris.clear.button=Schließen
options.iris.search.tooltip=Shader-Einstellungen durchsuchen (Strg + F)
options.iris.clear.tooltip=Shader-Suche beenden (Esc)
@@ -139,6 +139,10 @@ options.iris.shadowDistance.enabled=Allows you to change the maximum distance fo
options.iris.shadowDistance.disabled=Your current shader pack has already set a render distance for shadows; you cannot change it.
options.iris.gui.hide=Hide GUI
options.iris.gui.show=Show GUI
options.iris.search.button=Search
options.iris.clear.button=Clear
options.iris.search.tooltip=Search Shader Settings (Ctrl + F)
options.iris.clear.tooltip=Exit Shader Search (Esc)
options.dynamic_lights=Dynamic Lights
options.dynamic_lights.tooltip=Enables dynamic lights. Modes: OFF - off, FASTEST - 500ms delay, FAST - 250ms delay, FANCY - 50ms delay, REALTIME - no delay.
@@ -132,3 +132,7 @@ pack.iris.select.title=Выбрать
pack.iris.configure.title=Настроить
label.iris.true=Вкл
label.iris.false=Выкл
options.iris.search.button=Поиск
options.iris.clear.button=Очистить
options.iris.search.tooltip=Поиск настроек шейдеров (Ctrl + F)
options.iris.clear.tooltip=Выход из поиска шейдеров (Esc)
@@ -138,6 +138,10 @@ options.iris.shadowDistance.enabled=更改渲染阴影的最远距离。超出
options.iris.shadowDistance.disabled=当前光影包已重设阴影渲染距离,无法更改
options.iris.gui.hide=隐藏 GUI
options.iris.gui.show=显示 GUI
options.iris.search.button=搜索
options.iris.clear.button=清除搜索栏
options.iris.search.tooltip=搜索光影包设定 (Ctrl + F)
options.iris.clear.tooltip=退出搜索 (Esc)
options.dynamic_lights=动态光源
options.dynamic_lights.tooltip=启用动态光源,模式选择:OFF-关闭,FASTEST-500ms延迟,FAST-250ms延迟,FANCY-50ms延迟,REALTIME-无延迟