From ecc64e3184fa3fb57a06689e21c5714e50fac31f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 15:44:01 +0000 Subject: [PATCH 1/2] fix(ui): theme stroke, homepage previews, folder open, ClickGUI clip Keep Fast Render off by default. Draw rounded strokes as a ring so theme glass and background previews are not painted over with a hardcoded dark fill. Convert scissor to framebuffer pixels (and clamp) so expanded ClickGUI settings stay inside the window. Open background and local-music folders with explorer/xdg-open and raise the Explorer window on Windows. Co-authored-by: Gao Yu --- .../fpsmaster/cosmetic/CosmeticManager.java | 5 +- .../top/fpsmaster/ui/click/MainPanel.java | 10 + .../java/top/fpsmaster/ui/kit/EdgeCanvas.java | 17 +- .../java/top/fpsmaster/ui/kit/EdgeUi.java | 9 + .../screens/mainmenu/BackgroundSelector.java | 50 ++++- .../ui/screens/music/MusicScreen.java | 82 +++++-- .../ui/screens/replay/ReplayScreen.java | 14 +- .../top/fpsmaster/utils/io/FileUtils.java | 13 ++ .../fpsmaster/utils/render/draw/Rects.java | 59 +++++ .../fpsmaster/utils/render/gui/Scissor.java | 85 +++++-- .../fpsmaster/utils/system/FolderOpen.java | 210 ++++++++++++++++++ .../assets/minecraft/client/lang/en_us.lang | 2 + .../assets/minecraft/client/lang/zh_cn.lang | 2 + .../impl/optimizes/FastRenderDefaultTest.java | 18 ++ .../render/gui/ScissorIntersectTest.java | 25 +++ .../utils/system/FolderOpenTest.java | 39 ++++ 16 files changed, 585 insertions(+), 55 deletions(-) create mode 100644 src/main/java/top/fpsmaster/utils/system/FolderOpen.java create mode 100644 src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java create mode 100644 src/test/java/top/fpsmaster/utils/system/FolderOpenTest.java diff --git a/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java b/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java index bb8bcbad..7feb8d42 100644 --- a/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java +++ b/src/main/java/top/fpsmaster/cosmetic/CosmeticManager.java @@ -16,9 +16,9 @@ import top.fpsmaster.modules.client.api.model.CosmeticLoadoutView; import top.fpsmaster.modules.client.api.model.OwnedItemView; import top.fpsmaster.modules.logger.ClientLogger; +import top.fpsmaster.utils.system.FolderOpen; import javax.imageio.ImageIO; -import java.awt.Desktop; import java.awt.image.BufferedImage; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -120,10 +120,9 @@ public void reloadCustom() { public void openCustomDirectory() { reloadCustom(); try { - if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + if (!FolderOpen.open(customDirectory().toFile())) { throw new UnsupportedOperationException("Desktop folder opening is unavailable"); } - Desktop.getDesktop().open(customDirectory().toFile()); } catch (Exception exception) { ClientLogger.warn("Failed to open custom cosmetics folder: " + exception.getMessage()); } diff --git a/src/main/java/top/fpsmaster/ui/click/MainPanel.java b/src/main/java/top/fpsmaster/ui/click/MainPanel.java index b82a211a..64de53e0 100644 --- a/src/main/java/top/fpsmaster/ui/click/MainPanel.java +++ b/src/main/java/top/fpsmaster/ui/click/MainPanel.java @@ -50,6 +50,16 @@ public void initGui() { @Override public void render(int mouseX, int mouseY, float partialTicks) { + // SharedClickGui uses this same layout. Intersect every content clip with the + // panel so expanded settings cannot paint past the window (scissor is in + // framebuffer pixels; a huge extra-height rect is otherwise easy to get wrong). + float gw = guiWidth; + float gh = guiHeight; + float width = Math.min(490f, Math.max(300f, gw - 20f)); + float height = Math.min(310f, Math.max(220f, gh - 24f)); + float x = (gw - width) / 2f; + float y = (gh - height) / 2f; + EdgeUi.clipPanel(x + 1f, y + 1f, width - 2f, height - 2f); if (gui.draw(EdgeUi.frame(), bridge)) { mc.displayGuiScreen(null); if (mc.currentScreen == null) mc.setIngameFocus(); diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java b/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java index 150c306c..f56a4a67 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeCanvas.java @@ -19,11 +19,20 @@ final class EdgeCanvas implements Canvas { private final Deque clips = new ArrayDeque(); private final Deque alpha = new ArrayDeque(); + private static float[] panelClip; EdgeCanvas() { alpha.push(Float.valueOf(1f)); } + static void setPanelClip(float x, float y, float w, float h) { + panelClip = new float[] {x, y, w, h}; + } + + static void clearPanelClip() { + panelClip = null; + } + public void fillRect(float x, float y, float w, float h, int argb) { Rects.fill(x, y, w, h, tint(argb)); } @@ -43,9 +52,7 @@ public void fillRoundRect(float x, float y, float w, float h, float radius, int } public void strokeRoundRect(float x, float y, float w, float h, float radius, float strokeWidth, int argb) { - float s = Math.max(0.5f, strokeWidth); - fillRoundRect(x - s, y - s, w + s * 2f, h + s * 2f, radius + s, argb); - fillRoundRect(x, y, w, h, radius, Argb.of(255, 14, 14, 14)); + Rects.roundedOutline(x, y, w, h, radius, strokeWidth, tint(argb)); } public void fillCircle(float cx, float cy, float radius, int argb) { @@ -102,6 +109,10 @@ public void pushClip(float x, float y, float w, float h) { float[] next = parent == null ? new float[] {x, y, w, h} : Scissor.intersect(parent[0], parent[1], parent[2], parent[3], x, y, w, h); + if (panelClip != null) { + next = Scissor.intersect(panelClip[0], panelClip[1], panelClip[2], panelClip[3], + next[0], next[1], next[2], next[3]); + } clips.push(next); applyClip(next); } diff --git a/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java b/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java index 116b3bcb..2cfcdd68 100644 --- a/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java +++ b/src/main/java/top/fpsmaster/ui/kit/EdgeUi.java @@ -34,9 +34,18 @@ public static void beginOverlay(float guiWidth, float guiHeight) { public static void end() { fallback.endFrame(); + EdgeCanvas.clearPanelClip(); current = null; } + /** + * Extra GL clip intersected with every {@code pushClip}. Used so ClickGUI module + * settings cannot paint past the panel when an expanded card is taller than the window. + */ + public static void clipPanel(float x, float y, float w, float h) { + EdgeCanvas.setPanelClip(x, y, w, h); + } + public static UiFrame frame() { if (current == null) { throw new IllegalStateException("EdgeUi.begin() not called for this frame"); diff --git a/src/main/java/top/fpsmaster/ui/screens/mainmenu/BackgroundSelector.java b/src/main/java/top/fpsmaster/ui/screens/mainmenu/BackgroundSelector.java index 527977da..eb0e0733 100644 --- a/src/main/java/top/fpsmaster/ui/screens/mainmenu/BackgroundSelector.java +++ b/src/main/java/top/fpsmaster/ui/screens/mainmenu/BackgroundSelector.java @@ -12,6 +12,7 @@ import top.fpsmaster.modules.logger.ClientLogger; import top.fpsmaster.prism.screen.BackgroundsBridge; import top.fpsmaster.prism.screen.SharedBackgrounds; +import top.fpsmaster.prism.widget.Chrome; import top.fpsmaster.prism.widget.UiFrame; import top.fpsmaster.ui.kit.EdgeUi; import top.fpsmaster.utils.io.FileUtils; @@ -19,6 +20,7 @@ import top.fpsmaster.utils.render.draw.Rects; import top.fpsmaster.utils.render.gui.Backgrounds; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.system.FolderOpen; import javax.imageio.ImageIO; import java.awt.Color; @@ -51,7 +53,51 @@ public BackgroundSelector(GuiScreen parent) { @Override public void render(int mouseX, int mouseY, float partialTicks) { Backgrounds.draw((int) guiWidth, (int) guiHeight, mouseX, mouseY, partialTicks, (int) zLevel); - if (backgrounds.draw(EdgeUi.frame(), bridge)) mc.displayGuiScreen(parent); + UiFrame ui = EdgeUi.frame(); + if (backgrounds.draw(ui, bridge)) { + mc.displayGuiScreen(parent); + return; + } + drawOpenFolderButton(ui); + } + + /** + * SharedBackgrounds only exposes "choose image". The homepage flow also has an + * "open folder" control so users can drop {@code background.png} into the client dir. + */ + private void drawOpenFolderButton(UiFrame ui) { + float gw = ui.host().width(); + float gh = ui.host().height(); + float pw = Math.min(250f, gw - 24f); + int rows = (BackgroundsBridge.OPTIONS.length + 1) / 2; + boolean classic = "classic".equals(FPSMaster.configManager.configure.background); + float editorH = classic ? 78f : 0f; + float content = 8f + rows * (48f + 5f) + 8f + editorH; + float ph = Math.min(30f + content + 10f, Math.min(gh * 0.78f, gh - 24f)); + float px = (gw - pw) / 2f; + float py = (gh - ph) / 2f; + float headY = py + 8f; + String pick = FPSMaster.i18n.get("backgroundselector.pick"); + String folder = FPSMaster.i18n.get("backgroundselector.openfolder"); + float pickW = ui.font(13).measure(pick) + 16f; + float folderW = ui.font(13).measure(folder) + 16f; + float folderX = px + pw - 10f - pickW - 4f - folderW; + if (folderX < px + 28f) { + folderX = px + 28f; + } + if (Chrome.button(ui, folderX, headY, folderW, 15f, folder, Chrome.ButtonStyle.GHOST)) { + openBackgroundFolder(); + } + } + + private void openBackgroundFolder() { + File folder = FileUtils.dir; + if (folder == null && FileUtils.background != null) { + folder = FileUtils.background.getParentFile(); + } + if (!FolderOpen.open(folder)) { + ClientLogger.warn("Failed to open background folder"); + } } @Override @@ -135,6 +181,8 @@ public void paintPreview(UiFrame ui, String id, float x, float y, float w, float return; } if (id.startsWith("panorama_")) { + GL11.glEnable(GL11.GL_TEXTURE_2D); + GL11.glColor4f(1f, 1f, 1f, 1f); Images.draw(new ResourceLocation("client/background/" + id + "/panorama_0.png"), x, y, w, h, -1); return; } diff --git a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java index d4488f00..f5a8309c 100644 --- a/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/music/MusicScreen.java @@ -5,9 +5,10 @@ import org.lwjgl.input.Keyboard; import top.fpsmaster.FPSMaster; import top.fpsmaster.features.impl.interfaces.LyricsDisplay; +import top.fpsmaster.modules.config.ConfigProfileUtils; +import top.fpsmaster.modules.logger.ClientLogger; import top.fpsmaster.modules.music.MusicManager; import top.fpsmaster.modules.music.MusicTextures; -import top.fpsmaster.modules.config.ConfigProfileUtils; import top.fpsmaster.music.Lyric; import top.fpsmaster.music.LyricLine; import top.fpsmaster.music.MusicSource; @@ -16,15 +17,16 @@ import top.fpsmaster.music.Track; import top.fpsmaster.prism.screen.MusicBridge; import top.fpsmaster.prism.screen.SharedMusic; +import top.fpsmaster.prism.widget.Chrome; import top.fpsmaster.prism.widget.UiFrame; import top.fpsmaster.ui.kit.EdgeUi; +import top.fpsmaster.utils.io.FileUtils; import top.fpsmaster.utils.render.draw.Images; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.system.FolderOpen; -import java.io.IOException; import java.io.File; -import java.awt.FileDialog; -import java.awt.Frame; +import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -53,11 +55,41 @@ public MusicScreen(GuiScreen parent) { } catch (IllegalArgumentException ignored) { music.setPlaybackMode(MusicBridge.PlaybackMode.SEQUENTIAL); } + try { + refreshLocalTracks(FileUtils.musicDirectory()); + } catch (RuntimeException exception) { + ClientLogger.warn("Failed to scan local music folder: " + exception.getMessage()); + } } @Override public void render(int mouseX, int mouseY, float partialTicks) { - if (gui.draw(EdgeUi.frame(), bridge)) mc.displayGuiScreen(parent); + UiFrame ui = EdgeUi.frame(); + if (gui.draw(ui, bridge)) { + mc.displayGuiScreen(parent); + return; + } + drawOpenFolderButton(ui); + } + + private void drawOpenFolderButton(UiFrame ui) { + float gw = ui.host().width(); + float gh = ui.host().height(); + float pw = Math.min(480f, Math.max(320f, gw - 24f)); + float ph = Math.min(290f, Math.max(200f, gh - 32f)); + float px = (gw - pw) / 2f; + float py = (gh - ph) / 2f; + float size = 16f; + float closeX = px + pw - 7f - size; + float settingsX = closeX - 21f; + float folderX = settingsX - 21f; + float folderY = py + 7f; + String label = FPSMaster.i18n.get("music.local.openfolder"); + float labelW = ui.font(12).measure(label) + 16f; + float buttonX = folderX - labelW + size; + if (Chrome.button(ui, buttonX, folderY, labelW, size, label, Chrome.ButtonStyle.GHOST)) { + openLocalMusicFolder(); + } } @Override @@ -130,22 +162,7 @@ public List tracks() { } @Override public void importLocalMusic() { - Thread picker = new Thread(new Runnable() { - @Override public void run() { - FileDialog dialog = new FileDialog((Frame) null, "选择本地音乐", FileDialog.LOAD); - dialog.setMultipleMode(true); - dialog.setFilenameFilter((dir, name) -> isLocalAudio(name)); - dialog.setVisible(true); - final File[] files = dialog.getFiles(); - dialog.dispose(); - if (files.length == 0) return; - mc.addScheduledTask(new Runnable() { - @Override public void run() { localTracks = toLocalTracks(files); } - }); - } - }, "FPSMaster-Music-Picker"); - picker.setDaemon(true); - picker.start(); + openLocalMusicFolder(); } @Override public void playLocal(int index) { @@ -259,6 +276,29 @@ public List lyricRows() { } } + private void openLocalMusicFolder() { + File folder; + try { + folder = FileUtils.musicDirectory(); + } catch (IllegalStateException exception) { + ClientLogger.warn("Music folder is unavailable: " + exception.getMessage()); + return; + } + if (!FolderOpen.open(folder)) { + ClientLogger.warn("Failed to open local music folder: " + folder.getAbsolutePath()); + } + refreshLocalTracks(folder); + } + + private void refreshLocalTracks(File folder) { + File[] files = folder.listFiles(); + if (files == null) { + localTracks = new ArrayList(); + return; + } + localTracks = toLocalTracks(files); + } + private static String format(long millis) { long seconds = Math.max(0L, millis) / 1000L; return String.format("%d:%02d", seconds / 60L, seconds % 60L); diff --git a/src/main/java/top/fpsmaster/ui/screens/replay/ReplayScreen.java b/src/main/java/top/fpsmaster/ui/screens/replay/ReplayScreen.java index d7637af0..6201bf21 100644 --- a/src/main/java/top/fpsmaster/ui/screens/replay/ReplayScreen.java +++ b/src/main/java/top/fpsmaster/ui/screens/replay/ReplayScreen.java @@ -19,9 +19,9 @@ import top.fpsmaster.utils.render.draw.Rects; import top.fpsmaster.utils.render.gui.Backgrounds; import top.fpsmaster.utils.render.gui.ScaledGuiScreen; +import top.fpsmaster.utils.system.FolderOpen; import java.awt.Color; -import java.awt.Desktop; import java.io.File; import java.io.IOException; import java.text.SimpleDateFormat; @@ -529,10 +529,8 @@ private void openReplaysFolder() { if (!directory.isDirectory() && !directory.mkdirs()) { return; } - try { - Desktop.getDesktop().open(directory); - } catch (IOException | RuntimeException exception) { - ClientLogger.warn("Could not open replays folder: " + exception); + if (!FolderOpen.open(directory)) { + ClientLogger.warn("Could not open replays folder: " + directory.getAbsolutePath()); } } @@ -725,10 +723,8 @@ private void openEditsFolder() { if (!directory.isDirectory() && !directory.mkdirs()) { return; } - try { - Desktop.getDesktop().open(directory); - } catch (IOException | RuntimeException exception) { - ClientLogger.warn("Could not open edits folder: " + exception); + if (!FolderOpen.open(directory)) { + ClientLogger.warn("Could not open edits folder: " + directory.getAbsolutePath()); } } diff --git a/src/main/java/top/fpsmaster/utils/io/FileUtils.java b/src/main/java/top/fpsmaster/utils/io/FileUtils.java index 1d1f290c..450235d7 100644 --- a/src/main/java/top/fpsmaster/utils/io/FileUtils.java +++ b/src/main/java/top/fpsmaster/utils/io/FileUtils.java @@ -23,6 +23,7 @@ public class FileUtils extends Utility { public static File background; public static boolean hasBackground = false; public static File fonts; + public static File music; private static boolean initialized; @@ -35,11 +36,23 @@ public static synchronized void init(File dataDir) { } dir = ensureDir(new File(dataDir, "FPSMaster " + FPSMaster.EDITION)); fonts = ensureDir(new File(dir, "fonts")); + music = ensureDir(new File(dir, "music")); background = new File(dir, "background.png"); hasBackground = background.exists(); initialized = true; } + public static File musicDirectory() { + if (music != null && music.isDirectory()) { + return music; + } + if (dir == null) { + throw new IllegalStateException("FileUtils init requires a valid mcDataDir"); + } + music = ensureDir(new File(dir, "music")); + return music; + } + private static File ensureDir(File file) { if (!file.exists() && !file.mkdirs()) { ClientLogger.error("FileUtils", "failed to create directory: " + file.getAbsolutePath()); diff --git a/src/main/java/top/fpsmaster/utils/render/draw/Rects.java b/src/main/java/top/fpsmaster/utils/render/draw/Rects.java index 84f9e21c..b7639cb6 100644 --- a/src/main/java/top/fpsmaster/utils/render/draw/Rects.java +++ b/src/main/java/top/fpsmaster/utils/render/draw/Rects.java @@ -72,6 +72,65 @@ public static void roundedBorder(int x, int y, int width, int height, int radius rounded(x, y, width, height, radius, fill, false); } + /** + * Stroke only — does not fill the interior. Chrome draws the fill first, then this ring; + * covering the inside with a hardcoded dark cutout is what kept theme panels black and + * hid homepage background previews. + */ + public static void roundedOutline(float x, float y, float width, float height, float radius, float stroke, int color) { + if (width <= 0f || height <= 0f) { + return; + } + float s = Math.max(0.5f, stroke); + float r = Math.max(0f, Math.min(radius, Math.min(width, height) * 0.5f)); + if (r < 1f) { + fill(x, y, width, s, color); + fill(x, y + height - s, width, s, color); + fill(x, y + s, s, height - s * 2f, color); + fill(x + width - s, y + s, s, height - s * 2f, color); + return; + } + float inner = Math.max(0f, r - s); + fill(x + r, y, width - r * 2f, s, color); + fill(x + r, y + height - s, width - r * 2f, s, color); + fill(x, y + r, s, height - r * 2f, color); + fill(x + width - s, y + r, s, height - r * 2f, color); + quarterRing(x + r, y + r, inner, r, color, 180.0, 270.0); + quarterRing(x + width - r, y + r, inner, r, color, 270.0, 360.0); + quarterRing(x + r, y + height - r, inner, r, color, 90.0, 180.0); + quarterRing(x + width - r, y + height - r, inner, r, color, 0.0, 90.0); + } + + /** + * Quarter annulus in Minecraft GUI space (Y grows down): 0° is +X, 90° is +Y. + */ + private static void quarterRing(float cx, float cy, float inner, float outer, int color, double startDeg, double endDeg) { + if (outer <= 0f) { + return; + } + cx = UiScale.scale(cx); + cy = UiScale.scale(cy); + inner = UiScale.scale(inner); + outer = UiScale.scale(outer); + int segments = Math.max(8, Math.min(48, (int) (outer * 3f))); + GlStateManager.enableBlend(); + GlStateManager.disableTexture2D(); + GlStateManager.enableAlpha(); + glBlendFunc(GL11.GL_SRC_ALPHA, GL11.GL_ONE_MINUS_SRC_ALPHA); + glColor(color); + glBegin(GL11.GL_TRIANGLE_STRIP); + for (int i = 0; i <= segments; i++) { + double angle = Math.toRadians(startDeg + (endDeg - startDeg) * i / (double) segments); + double cos = Math.cos(angle); + double sin = Math.sin(angle); + glVertex2d(cx + cos * inner, cy + sin * inner); + glVertex2d(cx + cos * outer, cy + sin * outer); + } + glEnd(); + GlStateManager.enableTexture2D(); + GlStateManager.disableBlend(); + } + public static void roundedImage(int x, int y, int width, int height, int radius, Color color) { try { float pixelScale = UiScale.isActive() ? UiScale.getPixelScale() : 1.0f; diff --git a/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java b/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java index 0bf632a5..d5424918 100644 --- a/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java +++ b/src/main/java/top/fpsmaster/utils/render/gui/Scissor.java @@ -1,6 +1,7 @@ package top.fpsmaster.utils.render.gui; import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.ScaledResolution; import org.lwjgl.opengl.GL11; public class Scissor { @@ -29,32 +30,80 @@ public static float[] intersect( } public static void apply(float x, float y, float width, float height) { - float scale = UiScale.isActive() ? UiScale.getLayoutScale() : 1.0f; - applyScaled(x, y, width, height, scale); + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null) { + return; + } + if (UiScale.isActive()) { + applyScaled(x, y, width, height, UiScale.getLayoutScale(), + UiScale.getDisplayWidth(), UiScale.getDisplayHeight()); + return; + } + ScaledResolution resolution = new ScaledResolution(mc); + applyScaled(x, y, width, height, resolution.getScaleFactor(), + mc.displayWidth, mc.displayHeight); } public static void apply(float x, float y, float width, float height, float scaleFactor) { - applyScaled(x, y, width, height, scaleFactor); + Minecraft mc = Minecraft.getMinecraft(); + if (mc == null) { + return; + } + applyScaled(x, y, width, height, scaleFactor, mc.displayWidth, mc.displayHeight); } public static void apply(float x, float y, float width, float height, int scaleFactor) { - applyScaled(x, y, width, height, 2.0f); + apply(x, y, width, height, (float) scaleFactor); } - private static void applyScaled(float x, float y, float width, float height, float scaleFactor) { - if (Minecraft.getMinecraft().currentScreen == null) { - return; + /** + * Convert logical GUI coordinates to an {@code glScissor} box. + * + *

OpenGL scissor is in framebuffer pixels with the origin at the bottom-left. + * Minecraft GUI coordinates are top-down and (when a {@link ScaledResolution} + * or {@link UiScale} is active) not 1:1 with pixels. Passing GUI units straight + * to {@code glScissor} is the usual 1.8.9 overflow: the clip is too small, in the + * wrong place, or invalid (negative) so the driver ignores it and settings draw + * outside the window. + * + * @return {@code {fbX, fbY, fbW, fbH}} already clamped to the framebuffer + */ + public static int[] toFramebuffer( + float x, float y, float width, float height, + float scale, int displayWidth, int displayHeight + ) { + if (scale <= 0f || displayWidth <= 0 || displayHeight <= 0) { + return new int[] {0, 0, 0, 0}; + } + int left = Math.round(x * scale); + int top = Math.round(y * scale); + int right = Math.round((x + width) * scale); + int bottom = Math.round((y + height) * scale); + if (left < 0) { + left = 0; + } + if (top < 0) { + top = 0; + } + if (right > displayWidth) { + right = displayWidth; + } + if (bottom > displayHeight) { + bottom = displayHeight; } - int displayHeight = Minecraft.getMinecraft().displayHeight; - int sx = Math.round(x * scaleFactor); - int sy = Math.round(y * scaleFactor); - int sw = Math.round(width * scaleFactor); - int sh = Math.round(height * scaleFactor); - GL11.glScissor( - sx, - displayHeight - (sy + sh), - sw, - sh - ); + int fbW = right - left; + int fbH = bottom - top; + if (fbW <= 0 || fbH <= 0) { + return new int[] {0, 0, 0, 0}; + } + return new int[] {left, displayHeight - bottom, fbW, fbH}; + } + + private static void applyScaled( + float x, float y, float width, float height, + float scaleFactor, int displayWidth, int displayHeight + ) { + int[] box = toFramebuffer(x, y, width, height, scaleFactor, displayWidth, displayHeight); + GL11.glScissor(box[0], box[1], box[2], box[3]); } } diff --git a/src/main/java/top/fpsmaster/utils/system/FolderOpen.java b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java new file mode 100644 index 00000000..74eacbe3 --- /dev/null +++ b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java @@ -0,0 +1,210 @@ +package top.fpsmaster.utils.system; + +import com.sun.jna.Callback; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.win32.StdCallLibrary; +import top.fpsmaster.modules.logger.ClientLogger; + +import java.awt.Desktop; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; + +/** + * Opens a folder in the system file manager and, on Windows, tries to raise Explorer + * above the game window. {@link Desktop#open} often succeeds but leaves the window behind + * Minecraft's exclusive/always-on-top GL surface. + */ +public final class FolderOpen { + private static final int SW_RESTORE = 9; + private static final int ASFW_ANY = -1; + private static final long FOREGROUND_WAIT_MS = 1200L; + + private FolderOpen() { + } + + public static boolean open(File folder) { + File target = resolveDirectory(folder); + if (target == null) { + return false; + } + if (!ensureDirectory(target)) { + ClientLogger.warn("Failed to create folder: " + target.getAbsolutePath()); + return false; + } + if (OSUtil.isWindows()) { + if (openWindows(target)) { + return true; + } + } else if (OSUtil.isMac()) { + if (run(new String[] {"open", target.getAbsolutePath()})) { + return true; + } + } else if (run(new String[] {"xdg-open", target.getAbsolutePath()})) { + return true; + } + return desktopBrowse(target) || desktopOpen(target); + } + + public static boolean ensureDirectory(File folder) { + if (folder == null) { + return false; + } + if (folder.isDirectory()) { + return true; + } + return folder.mkdirs() && folder.isDirectory(); + } + + static File resolveDirectory(File folder) { + if (folder == null) { + return null; + } + File target = folder.isFile() ? folder.getParentFile() : folder; + return target; + } + + static String[] windowsExplorerArgs(File folder) { + return new String[] {"explorer.exe", folder.getAbsolutePath()}; + } + + private static boolean openWindows(File folder) { + String path = folder.getAbsolutePath(); + try { + new ProcessBuilder(windowsExplorerArgs(folder)).start(); + requestExplorerForeground(path); + return true; + } catch (IOException exception) { + ClientLogger.warn("explorer.exe failed for " + path + ": " + exception.getMessage()); + } + try { + // start gives the child its own process group so it can take foreground. + new ProcessBuilder("cmd", "/c", "start", "", "explorer.exe", path).start(); + requestExplorerForeground(path); + return true; + } catch (IOException exception) { + ClientLogger.warn("cmd start explorer failed for " + path + ": " + exception.getMessage()); + } + return desktopBrowse(folder) || desktopOpen(folder); + } + + private static void requestExplorerForeground(final String path) { + Thread raiser = new Thread(new Runnable() { + @Override + public void run() { + raiseExplorer(path); + } + }, "FPSMaster-FolderOpen"); + raiser.setDaemon(true); + raiser.start(); + } + + private static void raiseExplorer(String path) { + try { + User32 user32 = Native.load("user32", User32.class); + user32.AllowSetForegroundWindow(ASFW_ANY); + long deadline = System.currentTimeMillis() + FOREGROUND_WAIT_MS; + while (System.currentTimeMillis() < deadline) { + Pointer hwnd = findExplorerWindow(user32, path); + if (hwnd != null) { + user32.ShowWindow(hwnd, SW_RESTORE); + user32.SetForegroundWindow(hwnd); + return; + } + Thread.sleep(50L); + } + } catch (Throwable exception) { + ClientLogger.warn("Could not raise Explorer window: " + exception.getMessage()); + } + } + + private static Pointer findExplorerWindow(User32 user32, String path) { + final String needle = path == null ? "" : path.toLowerCase(Locale.ROOT); + final AtomicReference found = new AtomicReference(); + user32.EnumWindows(new User32.WndEnumProc() { + @Override + public boolean callback(Pointer hWnd, Pointer lParam) { + char[] classBuf = new char[64]; + user32.GetClassNameW(hWnd, classBuf, classBuf.length); + String className = Native.toString(classBuf); + if (!"CabinetWClass".equals(className) && !"ExploreWClass".equals(className)) { + return true; + } + if (needle.isEmpty()) { + found.set(hWnd); + return false; + } + char[] titleBuf = new char[512]; + user32.GetWindowTextW(hWnd, titleBuf, titleBuf.length); + String title = Native.toString(titleBuf).toLowerCase(Locale.ROOT); + if (title.contains(new File(needle).getName().toLowerCase(Locale.ROOT))) { + found.set(hWnd); + return false; + } + if (found.get() == null) { + found.set(hWnd); + } + return true; + } + }, Pointer.NULL); + return found.get(); + } + + private static boolean desktopBrowse(File folder) { + try { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { + return false; + } + URI uri = folder.toURI(); + Desktop.getDesktop().browse(uri); + return true; + } catch (Exception exception) { + ClientLogger.warn("Desktop.browse failed for " + folder + ": " + exception.getMessage()); + return false; + } + } + + private static boolean desktopOpen(File folder) { + try { + if (!Desktop.isDesktopSupported() || !Desktop.getDesktop().isSupported(Desktop.Action.OPEN)) { + return false; + } + Desktop.getDesktop().open(folder); + return true; + } catch (Exception exception) { + ClientLogger.warn("Desktop.open failed for " + folder + ": " + exception.getMessage()); + return false; + } + } + + private static boolean run(String[] command) { + try { + new ProcessBuilder(command).start(); + return true; + } catch (IOException exception) { + ClientLogger.warn("Failed to run " + command[0] + ": " + exception.getMessage()); + return false; + } + } + + interface User32 extends StdCallLibrary { + boolean AllowSetForegroundWindow(int dwProcessId); + + boolean SetForegroundWindow(Pointer hWnd); + + boolean ShowWindow(Pointer hWnd, int nCmdShow); + + boolean EnumWindows(WndEnumProc lpEnumFunc, Pointer arg); + + int GetClassNameW(Pointer hWnd, char[] lpClassName, int nMaxCount); + + int GetWindowTextW(Pointer hWnd, char[] lpString, int nMaxCount); + + interface WndEnumProc extends Callback { + boolean callback(Pointer hWnd, Pointer lParam); + } + } +} diff --git a/src/main/resources/assets/minecraft/client/lang/en_us.lang b/src/main/resources/assets/minecraft/client/lang/en_us.lang index 7819d04a..42c1786c 100644 --- a/src/main/resources/assets/minecraft/client/lang/en_us.lang +++ b/src/main/resources/assets/minecraft/client/lang/en_us.lang @@ -41,6 +41,7 @@ multiplayer.pin=Pin multiplayer.unpin=Unpin backgroundselector.title=Background Settings backgroundselector.pick=Choose Image +backgroundselector.openfolder=Open Folder backgroundselector.preview.image=IMG backgroundselector.filedialog.title=Select Background Image backgroundselector.option.classic.name=Solid Color @@ -1100,6 +1101,7 @@ time.yesterday=Yesterday time.date.fmt=MMM d configprofiles.foot=Stored in config/*.json music.notloggedin=Not logged in +music.local.openfolder=Open Folder replay.hud.by=recorded by %s replay.hud.hint=Esc returns the mouse to the camera; the timeline stays · drag to seek replay.hud.hint.fly=Esc brings back the cursor diff --git a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang index e8c68b30..856c52ed 100644 --- a/src/main/resources/assets/minecraft/client/lang/zh_cn.lang +++ b/src/main/resources/assets/minecraft/client/lang/zh_cn.lang @@ -41,6 +41,7 @@ multiplayer.pin=置顶 multiplayer.unpin=取消置顶 backgroundselector.title=背景设置 backgroundselector.pick=选择图片 +backgroundselector.openfolder=打开文件夹 backgroundselector.preview.image=IMG backgroundselector.filedialog.title=选择背景图片 backgroundselector.option.classic.name=纯色背景 @@ -1100,6 +1101,7 @@ time.yesterday=昨天 time.date.fmt=M 月 d 日 configprofiles.foot=存储于 config/*.json music.notloggedin=未登录账号 +music.local.openfolder=打开文件夹 replay.hud.by=%s 的录像 replay.hud.hint=按 ESC 收回鼠标,时间线保持显示 · 拖动时间轴跳转 replay.hud.hint.fly=按 ESC 呼出光标以操作控制条 diff --git a/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java b/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java new file mode 100644 index 00000000..6d8d65dc --- /dev/null +++ b/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java @@ -0,0 +1,18 @@ +package top.fpsmaster.features.impl.optimizes; + +import org.junit.jupiter.api.Test; +import top.fpsmaster.features.settings.impl.BooleanSetting; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +class FastRenderDefaultTest { + + @Test + void fastRenderSettingDefaultsOff() { + BooleanSetting setting = new BooleanSetting("FastRender", false); + assertFalse(setting.getValue(), "快速渲染 / Fast Render must default to off"); + setting.setValue(true); + setting.resetValue(); + assertFalse(setting.getValue()); + } +} diff --git a/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java b/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java index 94204970..3af49a7e 100644 --- a/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java +++ b/src/test/java/top/fpsmaster/utils/render/gui/ScissorIntersectTest.java @@ -40,4 +40,29 @@ void negativeExtentsDoNotExpandTheOtherRect() { float[] clipped = Scissor.intersect(0f, 0f, 20f, 20f, 4f, 4f, -8f, 10f); assertEquals(0f, clipped[2], 0.01f); } + + @Test + void framebufferScissorUsesScaleAndFlipsY() { + // 1920x1080, GUI scale 2: logical (100, 50, 200, 100) → fb (200, 780, 400, 200) + int[] box = Scissor.toFramebuffer(100f, 50f, 200f, 100f, 2f, 1920, 1080); + assertArrayEquals(new int[] {200, 780, 400, 200}, box); + } + + @Test + void framebufferScissorClampsExpandedSettingsToTheWindow() { + // Performance expanded: settings extra is taller than the display. + int[] box = Scissor.toFramebuffer(146f, 70f, 318f, 950f, 2f, 1920, 1080); + assertEquals(292, box[0]); + assertEquals(0, box[1]); + assertEquals(636, box[2]); + assertEquals(940, box[3]); + } + + @Test + void framebufferScissorRejectsEmptyOrInvalidInput() { + assertArrayEquals(new int[] {0, 0, 0, 0}, + Scissor.toFramebuffer(0f, 0f, 10f, 10f, 0f, 1920, 1080)); + assertArrayEquals(new int[] {0, 0, 0, 0}, + Scissor.toFramebuffer(0f, 0f, -4f, 10f, 2f, 1920, 1080)); + } } diff --git a/src/test/java/top/fpsmaster/utils/system/FolderOpenTest.java b/src/test/java/top/fpsmaster/utils/system/FolderOpenTest.java new file mode 100644 index 00000000..106713b5 --- /dev/null +++ b/src/test/java/top/fpsmaster/utils/system/FolderOpenTest.java @@ -0,0 +1,39 @@ +package top.fpsmaster.utils.system; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class FolderOpenTest { + + @Test + void resolveDirectoryUsesParentForFiles(@TempDir Path temp) { + File file = temp.resolve("background.png").toFile(); + assertEquals(temp.toFile(), FolderOpen.resolveDirectory(file)); + assertEquals(temp.toFile(), FolderOpen.resolveDirectory(temp.toFile())); + assertEquals(null, FolderOpen.resolveDirectory(null)); + } + + @Test + void ensureDirectoryCreatesMissingFolder(@TempDir Path temp) { + File nested = new File(temp.toFile(), "music"); + assertFalse(nested.exists()); + assertTrue(FolderOpen.ensureDirectory(nested)); + assertTrue(nested.isDirectory()); + assertTrue(FolderOpen.ensureDirectory(nested)); + } + + @Test + void windowsExplorerArgsPassTheAbsolutePath(@TempDir Path temp) { + File folder = temp.toFile(); + String[] args = FolderOpen.windowsExplorerArgs(folder); + assertEquals("explorer.exe", args[0]); + assertEquals(folder.getAbsolutePath(), args[1]); + } +} From 6697babe10c3e5a1bd06e9fe0c52bb79d2cafe05 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 15:50:07 +0000 Subject: [PATCH 2/2] fix(ui): treat missing files as files when resolving open-folder resolveDirectory used File.isFile(), which is false for a path that does not exist yet (background.png before the user drops one). Use the filename as a hint so Open Folder still targets the parent directory. Also lock Performance.fastRender's source default to false. Co-authored-by: Gao Yu --- .../fpsmaster/utils/system/FolderOpen.java | 10 ++++++++-- .../impl/optimizes/FastRenderDefaultTest.java | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/main/java/top/fpsmaster/utils/system/FolderOpen.java b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java index 74eacbe3..2f5d83e1 100644 --- a/src/main/java/top/fpsmaster/utils/system/FolderOpen.java +++ b/src/main/java/top/fpsmaster/utils/system/FolderOpen.java @@ -63,8 +63,14 @@ static File resolveDirectory(File folder) { if (folder == null) { return null; } - File target = folder.isFile() ? folder.getParentFile() : folder; - return target; + if (folder.isDirectory()) { + return folder; + } + File parent = folder.getParentFile(); + if (parent != null && (folder.isFile() || folder.getName().indexOf('.') >= 0)) { + return parent; + } + return folder; } static String[] windowsExplorerArgs(File folder) { diff --git a/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java b/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java index 6d8d65dc..39e9eeb6 100644 --- a/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java +++ b/src/test/java/top/fpsmaster/features/impl/optimizes/FastRenderDefaultTest.java @@ -3,7 +3,13 @@ import org.junit.jupiter.api.Test; import top.fpsmaster.features.settings.impl.BooleanSetting; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; class FastRenderDefaultTest { @@ -15,4 +21,18 @@ void fastRenderSettingDefaultsOff() { setting.resetValue(); assertFalse(setting.getValue()); } + + /** + * {@link Performance} pulls Minecraft through {@code Utility.mc}, so the production + * field cannot be constructed in this JVM. Lock the source default instead. + */ + @Test + void performanceSourceDefaultsFastRenderOff() throws Exception { + Path src = Paths.get("src/main/java/top/fpsmaster/features/impl/optimizes/Performance.java"); + String text = new String(Files.readAllBytes(src), StandardCharsets.UTF_8); + assertTrue(text.contains("new BooleanSetting(\"FastRender\", false)"), + "Performance.fastRender must be constructed with default false"); + assertFalse(text.contains("new BooleanSetting(\"FastRender\", true)"), + "Performance.fastRender must not default to true"); + } }